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

Bring customer email replies into the Messages inbox (#475)

* Bring customer email replies into the Messages inbox

* Address review findings on email replies

Newest messages win when a thread is longer than the page, a send cannot
be repeated with Enter while it runs, the open conversation re-reads when
the workshop is told of a new mail, and creating or updating a customer
claims the mail filed under their address. The notification opens the
thread the row went into, the reply domain counts only with the whole
receiving setup, our Message-ID also rides in References so a provider
rewriting it does not break matching, X-Auto-Response-Suppress alone no
longer drops a mail, and invoices on vehicle-linked records are recorded
under the vehicle's customer.

* Follow the search palette's new placeholder in the e2e spec
Bernt Christian Egeland 3 дней назад
Родитель
Сommit
9d978ecc1e
64 измененных файлов с 4708 добавлено и 346 удалено
  1. 14 1
      .github/workflows/deploy-prod.yml
  2. 12 1
      .github/workflows/deploy-staging.yml
  3. 1 1
      e2e/specs/vehicles/search.spec.ts
  4. 24 0
      messages/de/messaging.json
  5. 24 0
      messages/en/messaging.json
  6. 24 0
      messages/es/messaging.json
  7. 24 0
      messages/fr/messaging.json
  8. 24 0
      messages/it/messaging.json
  9. 24 0
      messages/lt/messaging.json
  10. 24 0
      messages/nb/messaging.json
  11. 24 0
      messages/nl/messaging.json
  12. 24 0
      messages/pl/messaging.json
  13. 24 0
      messages/pt-BR/messaging.json
  14. 24 0
      messages/pt-PT/messaging.json
  15. 24 0
      messages/ru/messaging.json
  16. 24 0
      messages/tr/messaging.json
  17. 192 277
      package-lock.json
  18. 1 0
      package.json
  19. 78 0
      prisma/migrations/20261029120000_email_replies/migration.sql
  20. 1 0
      prisma/schema/customers.prisma
  21. 83 0
      prisma/schema/messaging.prisma
  22. 10 5
      prisma/schema/organization.prisma
  23. 206 0
      src/__tests__/api/email-webhook-ses.test.ts
  24. 3 0
      src/__tests__/features/forms/clear-optional-fields.test.ts
  25. 281 0
      src/__tests__/features/messaging/inbound-mail.test.ts
  26. 141 0
      src/__tests__/features/messaging/outbound-mail.test.ts
  27. 281 0
      src/__tests__/features/messaging/receive-ses-mail.test.ts
  28. 9 1
      src/__tests__/features/messaging/unread-count.test.ts
  29. 1 0
      src/__tests__/lib/backup-file-urls.test.ts
  30. 68 0
      src/__tests__/lib/email-address.test.ts
  31. 1 0
      src/__tests__/lib/files/references.test.ts
  32. 183 0
      src/__tests__/lib/reply-mail.test.ts
  33. 155 0
      src/__tests__/lib/sns-signature.test.ts
  34. 11 6
      src/app/(authenticated)/messages/page.tsx
  35. 11 0
      src/app/api/protected/backup/export/route.ts
  36. 39 0
      src/app/api/protected/backup/import/route.ts
  37. 1 0
      src/app/api/protected/files/[...path]/route.ts
  38. 192 0
      src/app/api/webhooks/email/ses/route.ts
  39. 5 2
      src/features/customers/Actions/customerActions.ts
  40. 28 1
      src/features/email/Actions/emailActions.ts
  41. 52 10
      src/features/email/Lib/sendTemplatedMail.ts
  42. 248 0
      src/features/messaging/Actions/emailThreadActions.ts
  43. 98 16
      src/features/messaging/Actions/inboxActions.ts
  44. 6 1
      src/features/messaging/Components/ChannelBadge.tsx
  45. 486 0
      src/features/messaging/Components/EmailConversation.tsx
  46. 5 1
      src/features/messaging/Components/MessagesPageClient.tsx
  47. 112 0
      src/features/messaging/Components/NewEmailDialog.tsx
  48. 125 19
      src/features/messaging/Components/UnifiedInbox.tsx
  49. 56 0
      src/features/messaging/Lib/emailAttachments.server.ts
  50. 23 0
      src/features/messaging/Lib/emailChannel.server.ts
  51. 28 0
      src/features/messaging/Lib/emailClaim.server.ts
  52. 241 0
      src/features/messaging/Lib/inboundMail.server.ts
  53. 290 0
      src/features/messaging/Lib/inboundMail.ts
  54. 106 0
      src/features/messaging/Lib/outboundMail.server.ts
  55. 5 4
      src/features/messaging/Lib/unreadCount.ts
  56. 8 0
      src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts
  57. 16 0
      src/lib/backup/manifest.ts
  58. 51 0
      src/lib/email-address.ts
  59. 68 0
      src/lib/email.ts
  60. 1 0
      src/lib/files/references.ts
  61. 46 0
      src/lib/reply-mail.server.ts
  62. 170 0
      src/lib/reply-mail.ts
  63. 146 0
      src/lib/sns-signature.ts
  64. 1 0
      src/lib/upload-url.ts

+ 14 - 1
.github/workflows/deploy-prod.yml

@@ -54,6 +54,15 @@ jobs:
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
+          # Customer replies by email (Messages > email channel). The domain SES
+          # receives mail for, the bucket its receipt rule writes into, the
+          # region of both, and the topic whose deliveries the webhook accepts.
+          # All four or none: with any missing the channel stays off and
+          # customer mail goes out without a reply address, as before.
+          INBOUND_MAIL_DOMAIN: ${{ secrets.CLOUD_INBOUND_MAIL_DOMAIN }}
+          INBOUND_MAIL_BUCKET: ${{ secrets.CLOUD_INBOUND_MAIL_BUCKET }}
+          INBOUND_MAIL_REGION: ${{ secrets.CLOUD_INBOUND_MAIL_REGION }}
+          INBOUND_MAIL_TOPIC_ARN: ${{ secrets.CLOUD_INBOUND_MAIL_TOPIC_ARN }}
           # Optional. Next encrypts what a server action closes over with a key
           # Optional. Next encrypts what a server action closes over with a key
           # made fresh by every build, so a tab left open across a deploy can
           # made fresh by every build, so a tab left open across a deploy can
           # no longer be read by the new container. A fixed key keeps those tabs
           # no longer be read by the new container. A fixed key keeps those tabs
@@ -76,7 +85,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/prod
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|NEXT_SERVER_ACTIONS_)' > .env
 
 
           cat > docker-compose.yml << 'COMPOSE'
           cat > docker-compose.yml << 'COMPOSE'
           networks:
           networks:
@@ -111,6 +120,10 @@ jobs:
                 POSTHOG_KEY: ${POSTHOG_KEY}
                 POSTHOG_KEY: ${POSTHOG_KEY}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
+                INBOUND_MAIL_DOMAIN: ${INBOUND_MAIL_DOMAIN}
+                INBOUND_MAIL_BUCKET: ${INBOUND_MAIL_BUCKET}
+                INBOUND_MAIL_REGION: ${INBOUND_MAIL_REGION}
+                INBOUND_MAIL_TOPIC_ARN: ${INBOUND_MAIL_TOPIC_ARN}
                 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}

+ 12 - 1
.github/workflows/deploy-staging.yml

@@ -38,6 +38,13 @@ jobs:
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_INTEGRATIONS_ENCRYPTION_KEY }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_INTEGRATIONS_ENCRYPTION_KEY }}
+          # Customer replies by email; see deploy-prod.yml. Staging needs its
+          # own reply domain, bucket and topic, or these stay empty and the
+          # channel stays off there.
+          INBOUND_MAIL_DOMAIN: ${{ secrets.STAGING_INBOUND_MAIL_DOMAIN }}
+          INBOUND_MAIL_BUCKET: ${{ secrets.STAGING_INBOUND_MAIL_BUCKET }}
+          INBOUND_MAIL_REGION: ${{ secrets.STAGING_INBOUND_MAIL_REGION }}
+          INBOUND_MAIL_TOPIC_ARN: ${{ secrets.STAGING_INBOUND_MAIL_TOPIC_ARN }}
           # Optional. Next encrypts what a server action closes over with a key
           # Optional. Next encrypts what a server action closes over with a key
           # made fresh by every build, so a tab left open across a deploy can
           # made fresh by every build, so a tab left open across a deploy can
           # no longer be read by the new container. A fixed key keeps those tabs
           # no longer be read by the new container. A fixed key keeps those tabs
@@ -60,7 +67,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/staging
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
 
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|FIKEN_INTEGRATION_|INBOUND_MAIL_|NEXT_SERVER_ACTIONS_)' > .env
 
 
           cat > docker-compose.yml << 'COMPOSE'
           cat > docker-compose.yml << 'COMPOSE'
           networks:
           networks:
@@ -92,6 +99,10 @@ jobs:
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
+                INBOUND_MAIL_DOMAIN: ${INBOUND_MAIL_DOMAIN}
+                INBOUND_MAIL_BUCKET: ${INBOUND_MAIL_BUCKET}
+                INBOUND_MAIL_REGION: ${INBOUND_MAIL_REGION}
+                INBOUND_MAIL_TOPIC_ARN: ${INBOUND_MAIL_TOPIC_ARN}
                 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}

+ 1 - 1
e2e/specs/vehicles/search.spec.ts

@@ -33,7 +33,7 @@ test('a full phone number finds its customer in the global search', async ({ pag
   // the search down with it, the customer whose number it is included.
   // the search down with it, the customer whose number it is included.
   await page.goto('/vehicles')
   await page.goto('/vehicles')
   await settle(page)
   await settle(page)
-  const input = page.getByPlaceholder('Search by name, plate, phone, VIN, invoice…')
+  const input = page.getByPlaceholder('Search customers, plates, invoices, settings…')
   await expect(async () => {
   await expect(async () => {
     await page.keyboard.press('Control+k')
     await page.keyboard.press('Control+k')
     await expect(input).toBeVisible({ timeout: 2_000 })
     await expect(input).toBeVisible({ timeout: 2_000 })

+ 24 - 0
messages/de/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Als ungelesen markieren",
     "markUnread": "Als ungelesen markieren",
     "nothingToMarkUnread": "Der Kunde hat in dieser Unterhaltung noch nicht geschrieben.",
     "nothingToMarkUnread": "Der Kunde hat in dieser Unterhaltung noch nicht geschrieben.",
     "markUnreadFailed": "Die Unterhaltung konnte nicht als ungelesen markiert werden."
     "markUnreadFailed": "Die Unterhaltung konnte nicht als ungelesen markiert werden."
+  },
+  "email": {
+    "badge": "E-Mail",
+    "empty": "Noch keine Nachrichten in dieser Konversation.",
+    "placeholder": "Antwort schreiben",
+    "subjectPlaceholder": "Betreff",
+    "send": "Senden",
+    "sendFailed": "E-Mail konnte nicht gesendet werden",
+    "sentDocument": "Gesendet: {subject}",
+    "showQuoted": "Zitierten Text anzeigen",
+    "hideQuoted": "Zitierten Text ausblenden",
+    "spamFlag": "Der Mailfilter hat diese Nachricht als möglichen Spam markiert.",
+    "unknownSender": "Diese Adresse gehört noch zu keinem Kunden. Legen Sie ihn als Kunden an, um zu antworten.",
+    "ownVendorChoice": "Sie versenden E-Mails über Ihren eigenen Anbieter, daher gehen Antworten an diese Adresse.",
+    "ownVendorSettings": "E-Mail-Einstellungen",
+    "ownVendorThread": "Sie versenden E-Mails über Ihren eigenen Anbieter, daher landen Antworten dieses Kunden in Ihrem eigenen Postfach.",
+    "replyFromMailApp": "In Ihrem Mailprogramm antworten",
+    "attachment": "Anhang",
+    "compose": {
+      "title": "Neue E-Mail",
+      "searchPlaceholder": "Kunden durchsuchen",
+      "noMatches": "Kein Kunde passt zur Suche.",
+      "noRecipients": "Noch keine Kunden mit E-Mail-Adresse."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/en/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Mark as unread",
     "markUnread": "Mark as unread",
     "nothingToMarkUnread": "The customer has not written in this conversation yet.",
     "nothingToMarkUnread": "The customer has not written in this conversation yet.",
     "markUnreadFailed": "Could not mark the conversation as unread."
     "markUnreadFailed": "Could not mark the conversation as unread."
+  },
+  "email": {
+    "badge": "Email",
+    "empty": "No messages in this conversation yet.",
+    "placeholder": "Write a reply…",
+    "subjectPlaceholder": "Subject",
+    "send": "Send",
+    "sendFailed": "Could not send the email",
+    "sentDocument": "Sent: {subject}",
+    "showQuoted": "Show quoted text",
+    "hideQuoted": "Hide quoted text",
+    "spamFlag": "The mail filter flagged this message as possible spam.",
+    "unknownSender": "This address belongs to no customer yet. Add them as a customer to reply.",
+    "ownVendorChoice": "You send email through your own vendor, so replies go to that address.",
+    "ownVendorSettings": "Email settings",
+    "ownVendorThread": "You send email through your own vendor, so replies to this customer go to your own mailbox.",
+    "replyFromMailApp": "Reply from your mail app",
+    "attachment": "Attachment",
+    "compose": {
+      "title": "New email",
+      "searchPlaceholder": "Search customers",
+      "noMatches": "No customers match your search.",
+      "noRecipients": "No customers with an email address yet."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/es/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Marcar como no leído",
     "markUnread": "Marcar como no leído",
     "nothingToMarkUnread": "El cliente aún no ha escrito en esta conversación.",
     "nothingToMarkUnread": "El cliente aún no ha escrito en esta conversación.",
     "markUnreadFailed": "No se pudo marcar la conversación como no leída."
     "markUnreadFailed": "No se pudo marcar la conversación como no leída."
+  },
+  "email": {
+    "badge": "Correo",
+    "empty": "Aún no hay mensajes en esta conversación.",
+    "placeholder": "Escribe una respuesta",
+    "subjectPlaceholder": "Asunto",
+    "send": "Enviar",
+    "sendFailed": "No se pudo enviar el correo",
+    "sentDocument": "Enviado: {subject}",
+    "showQuoted": "Mostrar texto citado",
+    "hideQuoted": "Ocultar texto citado",
+    "spamFlag": "El filtro de correo marcó este mensaje como posible spam.",
+    "unknownSender": "Esta dirección aún no pertenece a ningún cliente. Añádelo como cliente para responder.",
+    "ownVendorChoice": "Envías el correo a través de tu propio proveedor, así que las respuestas llegan a esa dirección.",
+    "ownVendorSettings": "Configuración de correo",
+    "ownVendorThread": "Envías el correo a través de tu propio proveedor, así que las respuestas de este cliente llegan a tu propio buzón.",
+    "replyFromMailApp": "Responder desde tu aplicación de correo",
+    "attachment": "Adjunto",
+    "compose": {
+      "title": "Nuevo correo",
+      "searchPlaceholder": "Buscar clientes",
+      "noMatches": "Ningún cliente coincide con tu búsqueda.",
+      "noRecipients": "Aún no hay clientes con dirección de correo."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/fr/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Marquer comme non lu",
     "markUnread": "Marquer comme non lu",
     "nothingToMarkUnread": "Le client n'a pas encore écrit dans cette conversation.",
     "nothingToMarkUnread": "Le client n'a pas encore écrit dans cette conversation.",
     "markUnreadFailed": "Impossible de marquer la conversation comme non lue."
     "markUnreadFailed": "Impossible de marquer la conversation comme non lue."
+  },
+  "email": {
+    "badge": "E-mail",
+    "empty": "Aucun message dans cette conversation pour l'instant.",
+    "placeholder": "Écrire une réponse",
+    "subjectPlaceholder": "Objet",
+    "send": "Envoyer",
+    "sendFailed": "Impossible d'envoyer l'e-mail",
+    "sentDocument": "Envoyé : {subject}",
+    "showQuoted": "Afficher le texte cité",
+    "hideQuoted": "Masquer le texte cité",
+    "spamFlag": "Le filtre de messagerie a signalé ce message comme spam possible.",
+    "unknownSender": "Cette adresse n'appartient encore à aucun client. Ajoutez-le comme client pour répondre.",
+    "ownVendorChoice": "Vous envoyez vos e-mails via votre propre fournisseur, les réponses arrivent donc à cette adresse.",
+    "ownVendorSettings": "Paramètres e-mail",
+    "ownVendorThread": "Vous envoyez vos e-mails via votre propre fournisseur, les réponses de ce client arrivent donc dans votre propre boîte mail.",
+    "replyFromMailApp": "Répondre depuis votre messagerie",
+    "attachment": "Pièce jointe",
+    "compose": {
+      "title": "Nouvel e-mail",
+      "searchPlaceholder": "Rechercher des clients",
+      "noMatches": "Aucun client ne correspond à votre recherche.",
+      "noRecipients": "Aucun client avec une adresse e-mail pour l'instant."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/it/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Segna come non letto",
     "markUnread": "Segna come non letto",
     "nothingToMarkUnread": "Il cliente non ha ancora scritto in questa conversazione.",
     "nothingToMarkUnread": "Il cliente non ha ancora scritto in questa conversazione.",
     "markUnreadFailed": "Impossibile segnare la conversazione come non letta."
     "markUnreadFailed": "Impossibile segnare la conversazione come non letta."
+  },
+  "email": {
+    "badge": "Email",
+    "empty": "Ancora nessun messaggio in questa conversazione.",
+    "placeholder": "Scrivi una risposta",
+    "subjectPlaceholder": "Oggetto",
+    "send": "Invia",
+    "sendFailed": "Impossibile inviare l'email",
+    "sentDocument": "Inviato: {subject}",
+    "showQuoted": "Mostra testo citato",
+    "hideQuoted": "Nascondi testo citato",
+    "spamFlag": "Il filtro della posta ha segnalato questo messaggio come possibile spam.",
+    "unknownSender": "Questo indirizzo non appartiene ancora a nessun cliente. Aggiungilo come cliente per rispondere.",
+    "ownVendorChoice": "Invii le email tramite il tuo provider, quindi le risposte arrivano a quell'indirizzo.",
+    "ownVendorSettings": "Impostazioni email",
+    "ownVendorThread": "Invii le email tramite il tuo provider, quindi le risposte di questo cliente arrivano nella tua casella di posta.",
+    "replyFromMailApp": "Rispondi dalla tua app di posta",
+    "attachment": "Allegato",
+    "compose": {
+      "title": "Nuova email",
+      "searchPlaceholder": "Cerca clienti",
+      "noMatches": "Nessun cliente corrisponde alla ricerca.",
+      "noRecipients": "Ancora nessun cliente con indirizzo email."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/lt/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Pažymėti kaip neskaitytą",
     "markUnread": "Pažymėti kaip neskaitytą",
     "nothingToMarkUnread": "Klientas šiame pokalbyje dar nieko nerašė.",
     "nothingToMarkUnread": "Klientas šiame pokalbyje dar nieko nerašė.",
     "markUnreadFailed": "Nepavyko pažymėti pokalbio kaip neskaityto."
     "markUnreadFailed": "Nepavyko pažymėti pokalbio kaip neskaityto."
+  },
+  "email": {
+    "badge": "El. paštas",
+    "empty": "Šiame pokalbyje žinučių dar nėra.",
+    "placeholder": "Rašykite atsakymą",
+    "subjectPlaceholder": "Tema",
+    "send": "Siųsti",
+    "sendFailed": "Nepavyko išsiųsti el. laiško",
+    "sentDocument": "Išsiųsta: {subject}",
+    "showQuoted": "Rodyti cituojamą tekstą",
+    "hideQuoted": "Slėpti cituojamą tekstą",
+    "spamFlag": "Pašto filtras pažymėjo šią žinutę kaip galimą šlamštą.",
+    "unknownSender": "Šis adresas dar nepriskirtas klientui. Norėdami atsakyti, pridėkite jį kaip klientą.",
+    "ownVendorChoice": "El. laiškus siunčiate per savo tiekėją, todėl atsakymai ateina tuo adresu.",
+    "ownVendorSettings": "El. pašto nustatymai",
+    "ownVendorThread": "El. laiškus siunčiate per savo tiekėją, todėl šio kliento atsakymai ateina į jūsų pašto dėžutę.",
+    "replyFromMailApp": "Atsakyti pašto programoje",
+    "attachment": "Priedas",
+    "compose": {
+      "title": "Naujas el. laiškas",
+      "searchPlaceholder": "Ieškoti klientų",
+      "noMatches": "Pagal paiešką klientų nerasta.",
+      "noRecipients": "Dar nėra klientų su el. pašto adresu."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/nb/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Merk som ulest",
     "markUnread": "Merk som ulest",
     "nothingToMarkUnread": "Kunden har ikke skrevet i denne samtalen ennå.",
     "nothingToMarkUnread": "Kunden har ikke skrevet i denne samtalen ennå.",
     "markUnreadFailed": "Kunne ikke merke samtalen som ulest."
     "markUnreadFailed": "Kunne ikke merke samtalen som ulest."
+  },
+  "email": {
+    "badge": "E-post",
+    "empty": "Ingen meldinger i denne samtalen ennå.",
+    "placeholder": "Skriv et svar",
+    "subjectPlaceholder": "Emne",
+    "send": "Send",
+    "sendFailed": "Kunne ikke sende e-posten",
+    "sentDocument": "Sendt: {subject}",
+    "showQuoted": "Vis sitert tekst",
+    "hideQuoted": "Skjul sitert tekst",
+    "spamFlag": "E-postfilteret merket denne meldingen som mulig spam.",
+    "unknownSender": "Denne adressen tilhører ingen kunde ennå. Legg dem til som kunde for å svare.",
+    "ownVendorChoice": "Du sender e-post gjennom din egen leverandør, så svarene går til den adressen.",
+    "ownVendorSettings": "E-postinnstillinger",
+    "ownVendorThread": "Du sender e-post gjennom din egen leverandør, så svar fra denne kunden går til din egen innboks.",
+    "replyFromMailApp": "Svar fra e-postappen din",
+    "attachment": "Vedlegg",
+    "compose": {
+      "title": "Ny e-post",
+      "searchPlaceholder": "Søk etter kunder",
+      "noMatches": "Ingen kunder passer søket.",
+      "noRecipients": "Ingen kunder med e-postadresse ennå."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/nl/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Markeren als ongelezen",
     "markUnread": "Markeren als ongelezen",
     "nothingToMarkUnread": "De klant heeft in dit gesprek nog niets geschreven.",
     "nothingToMarkUnread": "De klant heeft in dit gesprek nog niets geschreven.",
     "markUnreadFailed": "Het gesprek kon niet als ongelezen worden gemarkeerd."
     "markUnreadFailed": "Het gesprek kon niet als ongelezen worden gemarkeerd."
+  },
+  "email": {
+    "badge": "E-mail",
+    "empty": "Nog geen berichten in dit gesprek.",
+    "placeholder": "Schrijf een antwoord",
+    "subjectPlaceholder": "Onderwerp",
+    "send": "Versturen",
+    "sendFailed": "Kon de e-mail niet versturen",
+    "sentDocument": "Verstuurd: {subject}",
+    "showQuoted": "Geciteerde tekst tonen",
+    "hideQuoted": "Geciteerde tekst verbergen",
+    "spamFlag": "Het mailfilter heeft dit bericht als mogelijke spam gemarkeerd.",
+    "unknownSender": "Dit adres hoort nog bij geen klant. Voeg ze als klant toe om te antwoorden.",
+    "ownVendorChoice": "Je verstuurt e-mail via je eigen provider, dus antwoorden komen op dat adres binnen.",
+    "ownVendorSettings": "E-mailinstellingen",
+    "ownVendorThread": "Je verstuurt e-mail via je eigen provider, dus antwoorden van deze klant komen in je eigen mailbox binnen.",
+    "replyFromMailApp": "Antwoord vanuit je mailapp",
+    "attachment": "Bijlage",
+    "compose": {
+      "title": "Nieuwe e-mail",
+      "searchPlaceholder": "Klanten zoeken",
+      "noMatches": "Geen klant komt overeen met je zoekopdracht.",
+      "noRecipients": "Nog geen klanten met een e-mailadres."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/pl/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Oznacz jako nieprzeczytane",
     "markUnread": "Oznacz jako nieprzeczytane",
     "nothingToMarkUnread": "Klient jeszcze nic nie napisał w tej rozmowie.",
     "nothingToMarkUnread": "Klient jeszcze nic nie napisał w tej rozmowie.",
     "markUnreadFailed": "Nie udało się oznaczyć rozmowy jako nieprzeczytanej."
     "markUnreadFailed": "Nie udało się oznaczyć rozmowy jako nieprzeczytanej."
+  },
+  "email": {
+    "badge": "E-mail",
+    "empty": "W tej rozmowie nie ma jeszcze wiadomości.",
+    "placeholder": "Napisz odpowiedź",
+    "subjectPlaceholder": "Temat",
+    "send": "Wyślij",
+    "sendFailed": "Nie udało się wysłać e-maila",
+    "sentDocument": "Wysłano: {subject}",
+    "showQuoted": "Pokaż cytowany tekst",
+    "hideQuoted": "Ukryj cytowany tekst",
+    "spamFlag": "Filtr poczty oznaczył tę wiadomość jako możliwy spam.",
+    "unknownSender": "Ten adres nie należy jeszcze do żadnego klienta. Dodaj go jako klienta, aby odpowiedzieć.",
+    "ownVendorChoice": "Wysyłasz e-maile przez własnego dostawcę, więc odpowiedzi trafiają na ten adres.",
+    "ownVendorSettings": "Ustawienia e-mail",
+    "ownVendorThread": "Wysyłasz e-maile przez własnego dostawcę, więc odpowiedzi tego klienta trafiają do Twojej skrzynki.",
+    "replyFromMailApp": "Odpowiedz w swojej aplikacji pocztowej",
+    "attachment": "Załącznik",
+    "compose": {
+      "title": "Nowy e-mail",
+      "searchPlaceholder": "Szukaj klientów",
+      "noMatches": "Żaden klient nie pasuje do wyszukiwania.",
+      "noRecipients": "Brak klientów z adresem e-mail."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/pt-BR/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Marcar como não lida",
     "markUnread": "Marcar como não lida",
     "nothingToMarkUnread": "O cliente ainda não escreveu nesta conversa.",
     "nothingToMarkUnread": "O cliente ainda não escreveu nesta conversa.",
     "markUnreadFailed": "Não foi possível marcar a conversa como não lida."
     "markUnreadFailed": "Não foi possível marcar a conversa como não lida."
+  },
+  "email": {
+    "badge": "E-mail",
+    "empty": "Ainda não há mensagens nesta conversa.",
+    "placeholder": "Escreva uma resposta",
+    "subjectPlaceholder": "Assunto",
+    "send": "Enviar",
+    "sendFailed": "Não foi possível enviar o e-mail",
+    "sentDocument": "Enviado: {subject}",
+    "showQuoted": "Mostrar texto citado",
+    "hideQuoted": "Ocultar texto citado",
+    "spamFlag": "O filtro de e-mail marcou esta mensagem como possível spam.",
+    "unknownSender": "Este endereço ainda não pertence a nenhum cliente. Cadastre-o como cliente para responder.",
+    "ownVendorChoice": "Você envia e-mails pelo seu próprio provedor, então as respostas chegam nesse endereço.",
+    "ownVendorSettings": "Configurações de e-mail",
+    "ownVendorThread": "Você envia e-mails pelo seu próprio provedor, então as respostas deste cliente chegam na sua própria caixa de entrada.",
+    "replyFromMailApp": "Responder pelo seu app de e-mail",
+    "attachment": "Anexo",
+    "compose": {
+      "title": "Novo e-mail",
+      "searchPlaceholder": "Buscar clientes",
+      "noMatches": "Nenhum cliente corresponde à sua busca.",
+      "noRecipients": "Ainda não há clientes com endereço de e-mail."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/pt-PT/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Marcar como não lida",
     "markUnread": "Marcar como não lida",
     "nothingToMarkUnread": "O cliente ainda não escreveu nesta conversa.",
     "nothingToMarkUnread": "O cliente ainda não escreveu nesta conversa.",
     "markUnreadFailed": "Não foi possível marcar a conversa como não lida."
     "markUnreadFailed": "Não foi possível marcar a conversa como não lida."
+  },
+  "email": {
+    "badge": "E-mail",
+    "empty": "Ainda não há mensagens nesta conversa.",
+    "placeholder": "Escreva uma resposta",
+    "subjectPlaceholder": "Assunto",
+    "send": "Enviar",
+    "sendFailed": "Não foi possível enviar o e-mail",
+    "sentDocument": "Enviado: {subject}",
+    "showQuoted": "Mostrar texto citado",
+    "hideQuoted": "Ocultar texto citado",
+    "spamFlag": "O filtro de correio assinalou esta mensagem como possível spam.",
+    "unknownSender": "Este endereço ainda não pertence a nenhum cliente. Registe-o como cliente para responder.",
+    "ownVendorChoice": "Envia e-mails através do seu próprio fornecedor, por isso as respostas chegam a esse endereço.",
+    "ownVendorSettings": "Definições de e-mail",
+    "ownVendorThread": "Envia e-mails através do seu próprio fornecedor, por isso as respostas deste cliente chegam à sua própria caixa de correio.",
+    "replyFromMailApp": "Responder na sua aplicação de e-mail",
+    "attachment": "Anexo",
+    "compose": {
+      "title": "Novo e-mail",
+      "searchPlaceholder": "Pesquisar clientes",
+      "noMatches": "Nenhum cliente corresponde à sua pesquisa.",
+      "noRecipients": "Ainda não há clientes com endereço de e-mail."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/ru/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Отметить как непрочитанное",
     "markUnread": "Отметить как непрочитанное",
     "nothingToMarkUnread": "Клиент ещё ничего не написал в этом разговоре.",
     "nothingToMarkUnread": "Клиент ещё ничего не написал в этом разговоре.",
     "markUnreadFailed": "Не удалось отметить разговор как непрочитанный."
     "markUnreadFailed": "Не удалось отметить разговор как непрочитанный."
+  },
+  "email": {
+    "badge": "Эл. почта",
+    "empty": "В этой переписке пока нет сообщений.",
+    "placeholder": "Напишите ответ",
+    "subjectPlaceholder": "Тема",
+    "send": "Отправить",
+    "sendFailed": "Не удалось отправить письмо",
+    "sentDocument": "Отправлено: {subject}",
+    "showQuoted": "Показать цитату",
+    "hideQuoted": "Скрыть цитату",
+    "spamFlag": "Почтовый фильтр пометил это сообщение как возможный спам.",
+    "unknownSender": "Этот адрес пока не привязан к клиенту. Добавьте его как клиента, чтобы ответить.",
+    "ownVendorChoice": "Вы отправляете письма через своего провайдера, поэтому ответы приходят на этот адрес.",
+    "ownVendorSettings": "Настройки эл. почты",
+    "ownVendorThread": "Вы отправляете письма через своего провайдера, поэтому ответы этого клиента приходят в ваш собственный почтовый ящик.",
+    "replyFromMailApp": "Ответить в почтовом приложении",
+    "attachment": "Вложение",
+    "compose": {
+      "title": "Новое письмо",
+      "searchPlaceholder": "Поиск клиентов",
+      "noMatches": "Клиентов по запросу не найдено.",
+      "noRecipients": "Пока нет клиентов с адресом эл. почты."
+    }
   }
   }
 }
 }

+ 24 - 0
messages/tr/messaging.json

@@ -26,5 +26,29 @@
     "markUnread": "Okunmadı olarak işaretle",
     "markUnread": "Okunmadı olarak işaretle",
     "nothingToMarkUnread": "Müşteri bu konuşmada henüz bir şey yazmadı.",
     "nothingToMarkUnread": "Müşteri bu konuşmada henüz bir şey yazmadı.",
     "markUnreadFailed": "Konuşma okunmadı olarak işaretlenemedi."
     "markUnreadFailed": "Konuşma okunmadı olarak işaretlenemedi."
+  },
+  "email": {
+    "badge": "E-posta",
+    "empty": "Bu görüşmede henüz mesaj yok.",
+    "placeholder": "Bir yanıt yazın",
+    "subjectPlaceholder": "Konu",
+    "send": "Gönder",
+    "sendFailed": "E-posta gönderilemedi",
+    "sentDocument": "Gönderildi: {subject}",
+    "showQuoted": "Alıntılanan metni göster",
+    "hideQuoted": "Alıntılanan metni gizle",
+    "spamFlag": "Posta filtresi bu mesajı olası spam olarak işaretledi.",
+    "unknownSender": "Bu adres henüz bir müşteriye ait değil. Yanıtlamak için müşteri olarak ekleyin.",
+    "ownVendorChoice": "E-postalarınızı kendi sağlayıcınız üzerinden gönderiyorsunuz, bu yüzden yanıtlar o adrese gider.",
+    "ownVendorSettings": "E-posta ayarları",
+    "ownVendorThread": "E-postalarınızı kendi sağlayıcınız üzerinden gönderiyorsunuz, bu yüzden bu müşterinin yanıtları kendi posta kutunuza gider.",
+    "replyFromMailApp": "E-posta uygulamanızdan yanıtlayın",
+    "attachment": "Ek",
+    "compose": {
+      "title": "Yeni e-posta",
+      "searchPlaceholder": "Müşteri ara",
+      "noMatches": "Aramanızla eşleşen müşteri yok.",
+      "noRecipients": "Henüz e-posta adresi olan müşteri yok."
+    }
   }
   }
 }
 }

Разница между файлами не показана из-за своего большого размера
+ 192 - 277
package-lock.json


+ 1 - 0
package.json

@@ -20,6 +20,7 @@
     "seed:demo": "npx tsx prisma/seed_dummy_data.ts"
     "seed:demo": "npx tsx prisma/seed_dummy_data.ts"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@aws-sdk/client-s3": "^3.1159.0",
     "@aws-sdk/client-ses": "^3.1045.0",
     "@aws-sdk/client-ses": "^3.1045.0",
     "@better-auth/passkey": "1.6.29",
     "@better-auth/passkey": "1.6.29",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/core": "^6.3.1",

+ 78 - 0
prisma/migrations/20261029120000_email_replies/migration.sql

@@ -0,0 +1,78 @@
+-- Customer email replies on the cloud instance: a per-workshop reply token
+-- and the two tables that hold the mail. New columns and tables only; no
+-- rows to move.
+
+-- AlterTable
+ALTER TABLE "organizations" ADD COLUMN "replyMailToken" TEXT;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "organizations_replyMailToken_key" ON "organizations"("replyMailToken");
+
+-- CreateTable
+CREATE TABLE "email_messages" (
+    "id" TEXT NOT NULL,
+    "direction" TEXT NOT NULL,
+    "kind" TEXT NOT NULL DEFAULT 'message',
+    "address" TEXT NOT NULL,
+    "fromName" TEXT,
+    "subject" TEXT,
+    "body" TEXT NOT NULL,
+    "messageId" TEXT,
+    "inReplyTo" TEXT,
+    "providerMessageId" TEXT,
+    "spamVerdict" TEXT,
+    "status" TEXT NOT NULL DEFAULT 'received',
+    "errorMessage" TEXT,
+    "relatedEntityType" TEXT,
+    "relatedEntityId" TEXT,
+    "readAt" TIMESTAMP(3),
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "customerId" TEXT,
+
+    CONSTRAINT "email_messages_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "email_attachments" (
+    "id" TEXT NOT NULL,
+    "fileName" TEXT NOT NULL,
+    "contentType" TEXT NOT NULL,
+    "size" INTEGER NOT NULL,
+    "url" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "messageId" TEXT NOT NULL,
+
+    CONSTRAINT "email_attachments_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "email_messages_providerMessageId_key" ON "email_messages"("providerMessageId");
+
+-- CreateIndex
+CREATE INDEX "email_messages_organizationId_customerId_createdAt_idx" ON "email_messages"("organizationId", "customerId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "email_messages_organizationId_address_createdAt_idx" ON "email_messages"("organizationId", "address", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "email_messages_organizationId_createdAt_idx" ON "email_messages"("organizationId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "email_messages_organizationId_readAt_idx" ON "email_messages"("organizationId", "readAt");
+
+-- CreateIndex
+CREATE INDEX "email_messages_organizationId_messageId_idx" ON "email_messages"("organizationId", "messageId");
+
+-- CreateIndex
+CREATE INDEX "email_attachments_messageId_idx" ON "email_attachments"("messageId");
+
+-- AddForeignKey
+ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "email_messages" ADD CONSTRAINT "email_messages_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "email_attachments" ADD CONSTRAINT "email_attachments_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "email_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 1 - 0
prisma/schema/customers.prisma

@@ -29,6 +29,7 @@ model Customer {
   smsMessages         SmsMessage[]
   smsMessages         SmsMessage[]
   telegramMessages    TelegramMessage[]
   telegramMessages    TelegramMessage[]
   whatsappMessages    WhatsappMessage[]
   whatsappMessages    WhatsappMessage[]
+  emailMessages       EmailMessage[]
   customerSessions    CustomerSession[]
   customerSessions    CustomerSession[]
   scheduledMessages   ScheduledMessage[]
   scheduledMessages   ScheduledMessage[]
   serviceRequests     ServiceRequest[]
   serviceRequests     ServiceRequest[]

+ 83 - 0
prisma/schema/messaging.prisma

@@ -167,3 +167,86 @@ model ScheduledMessage {
   @@index([status, sendAt])
   @@index([status, sendAt])
   @@map("scheduled_messages")
   @@map("scheduled_messages")
 }
 }
+
+/// One email between a workshop and a customer, in either direction.
+///
+/// Only the cloud instance writes these, and only for a workshop that sends
+/// through the platform's address: its customer mail carries a Reply-To of
+/// `reply-<token>@<reply domain>`, and what comes back to that address is
+/// filed here. A workshop with its own mail vendor gets replies in its own
+/// mailbox, so nothing of its mail is recorded.
+///
+/// `kind` tells a document mail (an invoice, a quote, a scheduled message)
+/// from a message written in the inbox. Document sends are kept so a reply
+/// shows what it answers; a thread is listed only once it holds a message,
+/// so sending invoices does not fill the inbox with one-sided threads.
+model EmailMessage {
+  id        String @id @default(cuid())
+  direction String // "inbound" | "outbound"
+  kind      String @default("message") // "message" | "document"
+
+  /// The customer's address, lower-cased: who wrote in, or who was written to.
+  address  String
+  /// The display name the sender's mail client gave, on inbound rows.
+  fromName String?
+  subject  String?
+  /// Plain text. An HTML-only mail is converted on receipt; HTML is never kept.
+  body     String
+
+  /// RFC 5322 Message-ID without the angle brackets: ours on outbound rows,
+  /// theirs on inbound ones. A reply's In-Reply-To finds the row it answers.
+  messageId         String?
+  inReplyTo         String?
+  /// The receiving service's own id for an inbound mail, which is also the
+  /// stored object's key. Unique, so a redelivered notification files nothing.
+  providerMessageId String? @unique
+  /// What the receiving service's spam filter said: PASS, FAIL or GRAY.
+  spamVerdict       String?
+
+  status       String  @default("received") // queued | sent | failed | received
+  errorMessage String?
+
+  relatedEntityType String?
+  relatedEntityId   String?
+
+  /// When the workshop first opened the thread this arrived in. Null on an
+  /// inbound message nobody has read yet; never set on an outbound row.
+  readAt    DateTime?
+  createdAt DateTime  @default(now())
+  updatedAt DateTime  @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+
+  attachments EmailAttachment[]
+
+  @@index([organizationId, customerId, createdAt(sort: Desc)])
+  @@index([organizationId, address, createdAt(sort: Desc)])
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([organizationId, readAt])
+  @@index([organizationId, messageId])
+  @@map("email_messages")
+}
+
+/// A file that came with an inbound email, kept as one of the workshop's
+/// uploads (folder `email`) and released through the file manager when the
+/// message goes.
+model EmailAttachment {
+  id          String   @id @default(cuid())
+  /// The name the sender gave it, cleaned for display; the file on disk has
+  /// a generated name.
+  fileName    String
+  contentType String
+  size        Int
+  url         String
+  createdAt   DateTime @default(now())
+
+  messageId String
+  message   EmailMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
+
+  @@index([messageId])
+  @@map("email_attachments")
+}

+ 10 - 5
prisma/schema/organization.prisma

@@ -32,11 +32,15 @@ model DashboardWidget {
 }
 }
 
 
 model Organization {
 model Organization {
-  id         String   @id @default(cuid())
-  name       String
-  portalSlug String?  @unique
-  createdAt  DateTime @default(now())
-  updatedAt  DateTime @updatedAt
+  id             String   @id @default(cuid())
+  name           String
+  portalSlug     String?  @unique
+  /// The secret in this workshop's reply address (`reply-<token>@…`) on the
+  /// cloud instance. Minted by the first customer mail that needs one; never
+  /// the organization id, which anyone could guess.
+  replyMailToken String?  @unique
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
 
 
   members                     OrganizationMember[]
   members                     OrganizationMember[]
   roles                       Role[]
   roles                       Role[]
@@ -58,6 +62,7 @@ model Organization {
   smsMessages                 SmsMessage[]
   smsMessages                 SmsMessage[]
   telegramMessages            TelegramMessage[]
   telegramMessages            TelegramMessage[]
   whatsappMessages            WhatsappMessage[]
   whatsappMessages            WhatsappMessage[]
+  emailMessages               EmailMessage[]
   technicians                 Technician[]
   technicians                 Technician[]
   workBays                    WorkBay[]
   workBays                    WorkBay[]
   timeEntries                 TimeEntry[]
   timeEntries                 TimeEntry[]

+ 206 - 0
src/__tests__/api/email-webhook-ses.test.ts

@@ -0,0 +1,206 @@
+// @vitest-environment node
+/**
+ * The door inbound mail comes through. It exists only on the cloud
+ * instance, accepts only deliveries SNS signed for our topic, confirms a
+ * subscription only on SNS's own host, and acknowledges or asks for a retry
+ * according to what filing the mail said.
+ */
+import { createSign, generateKeyPairSync } from 'node:crypto'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { config, receive, db, s3 } = vi.hoisted(() => ({
+  config: { ok: true as boolean, missing: [] as string[] },
+  receive: vi.fn(),
+  db: { systemSetting: { findMany: vi.fn() } },
+  s3: { send: vi.fn() },
+}))
+
+vi.mock('@/lib/reply-mail', () => ({
+  inboundMailConfig: () =>
+    config.ok
+      ? {
+          ok: true,
+          config: {
+            domain: 'reply.torqvoice.com',
+            bucket: 'torqvoice-inbound-mail',
+            region: 'eu-west-1',
+            topicArn: 'arn:aws:sns:eu-west-1:123456789012:inbound',
+          },
+        }
+      : { ok: false, missing: config.missing },
+}))
+vi.mock('@/features/messaging/Lib/inboundMail.server', () => ({ receiveSesMail: receive }))
+vi.mock('@/lib/db', () => ({ db }))
+vi.mock('@aws-sdk/client-s3', () => ({
+  S3Client: class {
+    send = s3.send
+  },
+  GetObjectCommand: class {
+    constructor(public input: unknown) {}
+  },
+}))
+
+import { POST } from '@/app/api/webhooks/email/ses/route'
+import { snsStringToSign, type SnsEnvelope } from '@/lib/sns-signature'
+
+const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
+const certificatePem = publicKey.export({ type: 'spki', format: 'pem' }) as string
+const CERT_URL = 'https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-abc.pem'
+
+const received = {
+  notificationType: 'Received',
+  mail: { messageId: 'ses-1' },
+  receipt: {
+    recipients: ['reply-abcdefghijklmnopqrstuvwxyz@reply.torqvoice.com'],
+    action: { type: 'S3', bucketName: 'torqvoice-inbound-mail', objectKey: 'ses-1' },
+  },
+}
+
+function envelope(overrides: Partial<SnsEnvelope> = {}): SnsEnvelope {
+  const unsigned: SnsEnvelope = {
+    Type: 'Notification',
+    MessageId: 'm-1',
+    TopicArn: 'arn:aws:sns:eu-west-1:123456789012:inbound',
+    Message: JSON.stringify(received),
+    Timestamp: '2026-10-29T10:00:00.000Z',
+    SignatureVersion: '2',
+    Signature: '',
+    SigningCertURL: CERT_URL,
+    ...overrides,
+  }
+  const signer = createSign('RSA-SHA256')
+  signer.update(snsStringToSign(unsigned) ?? '', 'utf8')
+  signer.end()
+  return { ...unsigned, Signature: signer.sign(privateKey).toString('base64') }
+}
+
+function post(body: unknown) {
+  return POST(
+    new Request('https://app.torqvoice.com/api/webhooks/email/ses', {
+      method: 'POST',
+      body: typeof body === 'string' ? body : JSON.stringify(body),
+    })
+  )
+}
+
+const fetchMock = vi.fn()
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  config.ok = true
+  vi.stubGlobal('fetch', fetchMock)
+  fetchMock.mockImplementation(async (url: string) => {
+    if (url === CERT_URL) return new Response(certificatePem, { status: 200 })
+    return new Response('', { status: 200 })
+  })
+  db.systemSetting.findMany.mockResolvedValue([
+    { key: 'ses.accessKeyId', value: 'AKIA' },
+    { key: 'ses.secretAccessKey', value: 'secret' },
+  ])
+  s3.send.mockResolvedValue({
+    ContentLength: 3,
+    Body: { transformToByteArray: async () => new Uint8Array([97, 98, 99]) },
+  })
+  receive.mockResolvedValue({ outcome: 'filed', id: 'em_1', organizationId: 'org_1' })
+  // The certificate cache lives on globalThis across tests.
+  ;(globalThis as { __snsCertificates?: Map<string, string> }).__snsCertificates?.clear()
+})
+
+afterEach(() => vi.unstubAllGlobals())
+
+describe('POST /api/webhooks/email/ses', () => {
+  it('does not exist off the cloud instance', async () => {
+    config.ok = false
+    config.missing = [
+      'INBOUND_MAIL_DOMAIN',
+      'INBOUND_MAIL_BUCKET',
+      'INBOUND_MAIL_REGION',
+      'INBOUND_MAIL_TOPIC_ARN',
+    ]
+    const response = await post(envelope())
+    expect(response.status).toBe(404)
+    expect(fetchMock).not.toHaveBeenCalled()
+    expect(receive).not.toHaveBeenCalled()
+  })
+
+  it('files a signed notification from our topic and acknowledges it', async () => {
+    const response = await post(envelope())
+    expect(response.status).toBe(200)
+    expect(receive).toHaveBeenCalledTimes(1)
+    const [mail, deps] = receive.mock.calls[0]
+    expect(mail).toMatchObject({ messageId: 'ses-1', bucket: 'torqvoice-inbound-mail' })
+
+    // The fetcher reads from the bucket named in the notification.
+    const object = await deps.fetchObject('torqvoice-inbound-mail', 'ses-1')
+    expect(object.size).toBe(3)
+    expect((await object.read()).toString()).toBe('abc')
+    expect(s3.send.mock.calls[0][0].input).toEqual({
+      Bucket: 'torqvoice-inbound-mail',
+      Key: 'ses-1',
+    })
+  })
+
+  it('refuses a delivery from another topic before fetching anything', async () => {
+    const response = await post(envelope({ TopicArn: 'arn:aws:sns:eu-west-1:1:other' }))
+    expect(response.status).toBe(403)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('refuses a bad signature and a certificate off SNS’s host', async () => {
+    const tampered = {
+      ...envelope(),
+      Message: JSON.stringify({ ...received, mail: { messageId: 'x' } }),
+    }
+    expect((await post(tampered)).status).toBe(403)
+    expect(receive).not.toHaveBeenCalled()
+
+    const elsewhere = envelope({ SigningCertURL: 'https://evil.example/cert.pem' })
+    expect((await post(elsewhere)).status).toBe(403)
+    expect(fetchMock).not.toHaveBeenCalledWith('https://evil.example/cert.pem', expect.anything())
+  })
+
+  it('refuses what is not an SNS message', async () => {
+    expect((await post('not json')).status).toBe(400)
+    expect((await post({ hello: 'world' })).status).toBe(400)
+  })
+
+  it('confirms a subscription only on SNS’s own host', async () => {
+    const good = envelope({
+      Type: 'SubscriptionConfirmation',
+      Token: 'tok',
+      SubscribeURL: 'https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription&Token=tok',
+    })
+    expect((await post(good)).status).toBe(200)
+    expect(fetchMock).toHaveBeenCalledWith(good.SubscribeURL, expect.anything())
+
+    fetchMock.mockClear()
+    const bad = envelope({
+      Type: 'SubscriptionConfirmation',
+      Token: 'tok',
+      SubscribeURL: 'https://evil.example/?Action=ConfirmSubscription',
+    })
+    expect((await post(bad)).status).toBe(400)
+    expect(fetchMock).not.toHaveBeenCalledWith(bad.SubscribeURL, expect.anything())
+    expect(receive).not.toHaveBeenCalled()
+  })
+
+  it('acknowledges a dropped mail and asks for a retry on a failure', async () => {
+    receive.mockResolvedValue({ outcome: 'dropped', reason: 'automated mail' })
+    expect((await post(envelope())).status).toBe(200)
+
+    receive.mockRejectedValue(new Error('database down'))
+    expect((await post(envelope())).status).toBe(500)
+  })
+
+  it('ignores a notification that is not a stored mail', async () => {
+    const bounce = envelope({ Message: JSON.stringify({ notificationType: 'Bounce' }) })
+    expect((await post(bounce)).status).toBe(200)
+    expect(receive).not.toHaveBeenCalled()
+  })
+
+  it('answers 503 when the storage credentials are missing, so SNS retries later', async () => {
+    db.systemSetting.findMany.mockResolvedValue([])
+    expect((await post(envelope())).status).toBe(503)
+    expect(receive).not.toHaveBeenCalled()
+  })
+})

+ 3 - 0
src/__tests__/features/forms/clear-optional-fields.test.ts

@@ -25,6 +25,9 @@ vi.mock('@/lib/features', () => ({
   FeatureGatedError: class FeatureGatedError extends Error {},
   FeatureGatedError: class FeatureGatedError extends Error {},
 }))
 }))
 vi.mock('@/lib/whatsapp', () => ({ claimWhatsappMessagesForCustomer: vi.fn() }))
 vi.mock('@/lib/whatsapp', () => ({ claimWhatsappMessagesForCustomer: vi.fn() }))
+vi.mock('@/features/messaging/Lib/emailClaim.server', () => ({
+  claimEmailMessagesForCustomer: vi.fn(),
+}))
 vi.mock('@/lib/notification-bus', () => ({ notificationBus: { emit: vi.fn() } }))
 vi.mock('@/lib/notification-bus', () => ({ notificationBus: { emit: vi.fn() } }))
 vi.mock('@/lib/document-lock.server', () => ({
 vi.mock('@/lib/document-lock.server', () => ({
   assertQuoteEditable: vi.fn().mockResolvedValue(undefined),
   assertQuoteEditable: vi.fn().mockResolvedValue(undefined),

+ 281 - 0
src/__tests__/features/messaging/inbound-mail.test.ts

@@ -0,0 +1,281 @@
+// @vitest-environment node
+/**
+ * The rules that decide what an inbound email is before it is stored: which
+ * notifications count, which mails were written by machines, where a reply
+ * ends and the quoted mail begins, and which files are kept.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  cleanAttachmentName,
+  cleanBody,
+  cleanSubject,
+  headerView,
+  isAutomatedMail,
+  KEPT_ATTACHMENT_TYPES,
+  MAX_ATTACHMENT_BYTES,
+  MAX_ATTACHMENTS,
+  MAX_BODY_CHARS,
+  MAX_TOTAL_ATTACHMENT_BYTES,
+  parseSesReceivedNotification,
+  replySubject,
+  selectAttachments,
+  splitQuotedReply,
+} from '@/features/messaging/Lib/inboundMail'
+
+const notification = {
+  notificationType: 'Received',
+  mail: {
+    messageId: 'd6iitobk75ur44p8kdnnp7g2n800',
+    source: 'kari@example.com',
+    destination: ['reply-abcdefghijklmnopqrstuvwxyz@reply.torqvoice.com'],
+  },
+  receipt: {
+    recipients: ['reply-abcdefghijklmnopqrstuvwxyz@reply.torqvoice.com'],
+    spamVerdict: { status: 'PASS' },
+    virusVerdict: { status: 'PASS' },
+    action: {
+      type: 'S3',
+      bucketName: 'torqvoice-inbound-mail',
+      objectKey: 'd6iitobk75ur44p8kdnnp7g2n800',
+    },
+  },
+}
+
+describe('parseSesReceivedNotification', () => {
+  it('reads the parts the inbox acts on', () => {
+    expect(parseSesReceivedNotification(notification)).toEqual({
+      messageId: 'd6iitobk75ur44p8kdnnp7g2n800',
+      bucket: 'torqvoice-inbound-mail',
+      objectKey: 'd6iitobk75ur44p8kdnnp7g2n800',
+      recipients: ['reply-abcdefghijklmnopqrstuvwxyz@reply.torqvoice.com'],
+      spamVerdict: 'PASS',
+      virusVerdict: 'PASS',
+    })
+  })
+
+  it('refuses other notification types, other actions and broken shapes', () => {
+    expect(parseSesReceivedNotification({ ...notification, notificationType: 'Bounce' })).toBeNull()
+    expect(
+      parseSesReceivedNotification({
+        ...notification,
+        receipt: { ...notification.receipt, action: { type: 'SNS' } },
+      })
+    ).toBeNull()
+    expect(
+      parseSesReceivedNotification({
+        ...notification,
+        receipt: { ...notification.receipt, recipients: [] },
+      })
+    ).toBeNull()
+    expect(
+      parseSesReceivedNotification({
+        ...notification,
+        mail: { ...notification.mail, messageId: '../etc/passwd' },
+      })
+    ).toBeNull()
+    expect(parseSesReceivedNotification(null)).toBeNull()
+    expect(parseSesReceivedNotification('x')).toBeNull()
+  })
+
+  it('keeps going without verdicts', () => {
+    const parsed = parseSesReceivedNotification({
+      ...notification,
+      receipt: { recipients: notification.receipt.recipients, action: notification.receipt.action },
+    })
+    expect(parsed?.spamVerdict).toBeNull()
+    expect(parsed?.virusVerdict).toBeNull()
+  })
+})
+
+describe('isAutomatedMail', () => {
+  const human = headerView(new Map([['content-type', { value: 'text/plain' }]]))
+
+  it('lets a person’s mail through', () => {
+    expect(isAutomatedMail(human, 'kari@example.com')).toBe(false)
+  })
+
+  it('drops out-of-office replies, bounces and list mail on their headers', () => {
+    expect(isAutomatedMail(headerView({ 'auto-submitted': 'auto-replied' }), 'k@x.no')).toBe(true)
+    expect(isAutomatedMail(headerView({ 'auto-submitted': 'no' }), 'k@x.no')).toBe(false)
+    expect(isAutomatedMail(headerView({ 'x-auto-response-suppress': 'All' }), 'k@x.no')).toBe(true)
+    expect(
+      isAutomatedMail(headerView({ 'x-auto-response-suppress': 'OOF, AutoReply' }), 'k@x.no')
+    ).toBe(true)
+    // A person's mail may carry the header too, declining receipts or saying nothing.
+    expect(isAutomatedMail(headerView({ 'x-auto-response-suppress': 'None' }), 'k@x.no')).toBe(
+      false
+    )
+    expect(isAutomatedMail(headerView({ 'x-auto-response-suppress': 'DR, RN' }), 'k@x.no')).toBe(
+      false
+    )
+    expect(isAutomatedMail(headerView({ precedence: 'bulk' }), 'k@x.no')).toBe(true)
+    expect(isAutomatedMail(headerView({ precedence: 'first-class' }), 'k@x.no')).toBe(false)
+    expect(
+      isAutomatedMail(headerView({ 'content-type': { value: 'multipart/report' } }), 'k@x.no')
+    ).toBe(true)
+    expect(isAutomatedMail(headerView({ 'list-unsubscribe': '<mailto:x>' }), 'k@x.no')).toBe(true)
+  })
+
+  it('drops mail from system senders whatever the headers say', () => {
+    expect(isAutomatedMail(human, 'mailer-daemon@googlemail.com')).toBe(true)
+    expect(isAutomatedMail(human, 'postmaster@example.com')).toBe(true)
+    expect(isAutomatedMail(human, 'noreply@bank.example')).toBe(true)
+    expect(isAutomatedMail(human, 'no-reply@shop.example')).toBe(true)
+    expect(isAutomatedMail(human, 'donotreply@gov.example')).toBe(true)
+    expect(isAutomatedMail(human, 'bounces+123@mailer.example')).toBe(true)
+    // A person whose name happens to start with one of the words is still a person.
+    expect(isAutomatedMail(human, 'noreplyson@example.com')).toBe(false)
+    expect(isAutomatedMail(human, 'bouncer@example.com')).toBe(false)
+  })
+})
+
+describe('splitQuotedReply', () => {
+  it('cuts at the attribution line a mail client writes above the quote', () => {
+    const text = [
+      'Yes, Tuesday works for me.',
+      '',
+      'On Mon, 27 Oct 2026 at 10:12, EF Moto <post@torqvoice.com> wrote:',
+      '> Your invoice is attached.',
+      '> Regards',
+    ].join('\n')
+    expect(splitQuotedReply(text)).toEqual({
+      reply: 'Yes, Tuesday works for me.',
+      quoted: [
+        'On Mon, 27 Oct 2026 at 10:12, EF Moto <post@torqvoice.com> wrote:',
+        '> Your invoice is attached.',
+        '> Regards',
+      ].join('\n'),
+    })
+  })
+
+  it('handles an attribution line a client wrapped over two lines', () => {
+    const text = [
+      'Takk, det passer.',
+      '',
+      'man. 27. okt. 2026 kl. 10:12 skrev EF Moto',
+      '<post@torqvoice.com>:',
+      '',
+      '> Faktura vedlagt.',
+    ].join('\n')
+    expect(splitQuotedReply(text).reply).toBe('Takk, det passer.')
+  })
+
+  it('reads the attribution in other languages', () => {
+    for (const line of [
+      'Am 27.10.2026 um 10:12 schrieb EF Moto <post@torqvoice.com>:',
+      'Le lun. 27 oct. 2026 à 10:12, EF Moto <post@torqvoice.com> a écrit :',
+      'El lun, 27 oct 2026 a las 10:12, EF Moto (<post@torqvoice.com>) escribió:',
+      'Il giorno lun 27 ott 2026 alle ore 10:12 EF Moto <post@torqvoice.com> ha scritto:',
+      'Op ma 27 okt 2026 om 10:12 schreef EF Moto <post@torqvoice.com>:',
+      'W dniu pon., 27 paź 2026 o 10:12 EF Moto <post@torqvoice.com> napisał(a):',
+      'Em seg., 27 de out. de 2026 às 10:12, EF Moto <post@torqvoice.com> escreveu:',
+      'пн, 27 окт. 2026 г. в 10:12, EF Moto <post@torqvoice.com> написал:',
+      '27 Eki 2026 Pzt 10:12 tarihinde EF Moto <post@torqvoice.com> şunu yazdı:',
+      'Den man. 27. okt. 2026 kl. 10:12 skrev EF Moto <post@torqvoice.com>:',
+    ]) {
+      const result = splitQuotedReply(`Ok.\n\n${line}\n> old`)
+      expect(result.reply, line).toBe('Ok.')
+    }
+  })
+
+  it('cuts at Outlook’s separator and at its header block', () => {
+    expect(
+      splitQuotedReply('Fine by me.\n\n-----Original Message-----\nFrom: EF Moto\nSent: Monday')
+        .reply
+    ).toBe('Fine by me.')
+    expect(
+      splitQuotedReply(
+        'Fine by me.\n\n________________________________\nFrom: EF Moto\nSent: Monday'
+      ).reply
+    ).toBe('Fine by me.')
+    expect(
+      splitQuotedReply('Fine by me.\n\nFrom: EF Moto <post@torqvoice.com>\nSent: Monday\nTo: me')
+        .reply
+    ).toBe('Fine by me.')
+  })
+
+  it('does not mistake a sentence beginning with From for a header block', () => {
+    const text = 'From: what I can see the brakes are fine.\nThanks.'
+    expect(splitQuotedReply(text)).toEqual({ reply: text, quoted: '' })
+  })
+
+  it('does not mistake prose with the word wrote in it for an attribution', () => {
+    const text = 'The mechanic wrote:\nbrakes are fine.\nThanks.'
+    expect(splitQuotedReply(text)).toEqual({ reply: text, quoted: '' })
+  })
+
+  it('cuts at a signature separator', () => {
+    expect(splitQuotedReply('See you then.\n-- \nKari\nkari@example.com').reply).toBe(
+      'See you then.'
+    )
+  })
+
+  it('returns a mail that is all quote whole, so nothing is lost', () => {
+    const text = '> only quoted\n> lines'
+    expect(splitQuotedReply(text)).toEqual({ reply: text, quoted: '' })
+  })
+
+  it('leaves a mail with no quote alone', () => {
+    expect(splitQuotedReply('Just a question: is it ready?\r\nThanks')).toEqual({
+      reply: 'Just a question: is it ready?\nThanks',
+      quoted: '',
+    })
+  })
+})
+
+describe('attachments', () => {
+  const file = (contentType: string, size: number, filename = 'a.bin') => ({
+    filename,
+    contentType,
+    size,
+  })
+
+  it('keeps only the types the file route can serve, within the limits', () => {
+    expect(KEPT_ATTACHMENT_TYPES.has('application/pdf')).toBe(true)
+    expect(KEPT_ATTACHMENT_TYPES.has('text/html')).toBe(false)
+    expect(KEPT_ATTACHMENT_TYPES.has('application/x-msdownload')).toBe(false)
+    expect(
+      selectAttachments([
+        file('image/jpeg', 1000),
+        file('text/html', 10),
+        file('application/pdf', MAX_ATTACHMENT_BYTES + 1),
+        file('application/pdf', 0),
+        file('IMAGE/PNG', 500),
+      ])
+    ).toEqual([0, 4])
+  })
+
+  it('stops at the count and the total size', () => {
+    const many = Array.from({ length: MAX_ATTACHMENTS + 3 }, () => file('image/jpeg', 10))
+    expect(selectAttachments(many)).toHaveLength(MAX_ATTACHMENTS)
+
+    const big = MAX_TOTAL_ATTACHMENT_BYTES / 2 + 1
+    expect(selectAttachments([file('image/jpeg', big), file('image/jpeg', big)])).toEqual([0])
+  })
+
+  it('cleans a name for display and gives it the stored extension', () => {
+    expect(cleanAttachmentName('../../etc/passwd', 'pdf')).toBe('.. .. etc passwd.pdf')
+    expect(cleanAttachmentName('Faktura "2026".PDF', 'pdf')).toBe('Faktura 2026.PDF')
+    expect(cleanAttachmentName('bilde\r\nContent-Type: x', 'jpg')).toBe('bilde Content-Type: x.jpg')
+    expect(cleanAttachmentName('', 'jpg')).toBe('attachment.jpg')
+    expect(cleanAttachmentName(null, 'png')).toBe('attachment.png')
+    expect(cleanAttachmentName('x'.repeat(300), 'jpg')).toHaveLength(124)
+  })
+})
+
+describe('subjects and bodies', () => {
+  it('makes a subject one line and a reply subject once', () => {
+    expect(cleanSubject(' Re:\r\n invoice  ')).toBe('Re: invoice')
+    expect(cleanSubject('')).toBeNull()
+    expect(replySubject('Invoice 2026-104')).toBe('Re: Invoice 2026-104')
+    expect(replySubject('Re: Invoice 2026-104')).toBe('Re: Invoice 2026-104')
+    expect(replySubject('SV: Faktura')).toBe('SV: Faktura')
+    expect(replySubject(null)).toBe('Re:')
+  })
+
+  it('normalises line endings and cuts a body at the limit', () => {
+    expect(cleanBody('a\r\nb\rc\n  ')).toBe('a\nb\nc')
+    expect(cleanBody('x'.repeat(MAX_BODY_CHARS + 5))).toHaveLength(MAX_BODY_CHARS)
+    expect(cleanBody(undefined)).toBe('')
+  })
+})

+ 141 - 0
src/__tests__/features/messaging/outbound-mail.test.ts

@@ -0,0 +1,141 @@
+// @vitest-environment node
+/**
+ * What a customer mail gets on the cloud instance so the answer comes back:
+ * the workshop's name as sender, the reply address, a Message-ID of ours,
+ * and a row in the thread. And, as importantly, what it gets everywhere
+ * else: nothing.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { db, email } = vi.hoisted(() => ({
+  db: {
+    organization: { findUnique: vi.fn(), updateMany: vi.fn() },
+    emailMessage: { create: vi.fn() },
+  },
+  email: {
+    usesPlatformMail: vi.fn(async () => true),
+    platformFromAddressAs: vi.fn(async (name: string) => `"${name}" <post@torqvoice.com>`),
+  },
+}))
+vi.mock('@/lib/db', () => ({ db }))
+vi.mock('@/lib/email', () => email)
+vi.mock('@/lib/cloud-instance', () => ({ isCloudInstance: vi.fn(() => true) }))
+
+import { isCloudInstance } from '@/lib/cloud-instance'
+import { prepareCustomerMail } from '@/features/messaging/Lib/outboundMail.server'
+import { ensureReplyMailToken } from '@/lib/reply-mail.server'
+
+const TOKEN = 'abcdefghijklmnopqrstuvwxyz'
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.stubEnv('INBOUND_MAIL_DOMAIN', 'reply.torqvoice.com')
+  vi.stubEnv('INBOUND_MAIL_BUCKET', 'torqvoice-inbound-mail')
+  vi.stubEnv('INBOUND_MAIL_REGION', 'eu-west-1')
+  vi.stubEnv('INBOUND_MAIL_TOPIC_ARN', 'arn:aws:sns:eu-west-1:123456789012:inbound')
+  vi.mocked(isCloudInstance).mockReturnValue(true)
+  email.usesPlatformMail.mockResolvedValue(true)
+  db.organization.findUnique.mockResolvedValue({ name: 'EF Moto', replyMailToken: TOKEN })
+  db.emailMessage.create.mockResolvedValue({ id: 'em_1' })
+})
+
+afterEach(() => vi.unstubAllEnvs())
+
+describe('prepareCustomerMail', () => {
+  it('names the workshop, points replies at the reply address and records the send', async () => {
+    const prepared = await prepareCustomerMail('org_1', 'Kari <Kari@Example.com>', {
+      customerId: 'cust_1',
+      kind: 'document',
+      relatedEntityType: 'service_record',
+      relatedEntityId: 'sr_1',
+    })
+    expect(prepared).not.toBeNull()
+    expect(prepared?.headers.from).toBe('"EF Moto" <post@torqvoice.com>')
+    expect(prepared?.headers.replyTo).toBe(`reply-${TOKEN}@reply.torqvoice.com`)
+    expect(prepared?.headers.messageId).toMatch(/@reply\.torqvoice\.com$/)
+    // Also in References, which survives a provider rewriting Message-ID.
+    expect(prepared?.headers.references).toEqual([prepared?.headers.messageId])
+
+    await prepared?.record({ status: 'sent', subject: 'Invoice 2026-104', body: 'See attached' })
+    expect(db.emailMessage.create).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({
+          direction: 'outbound',
+          kind: 'document',
+          address: 'kari@example.com',
+          subject: 'Invoice 2026-104',
+          body: 'See attached',
+          messageId: prepared?.headers.messageId,
+          status: 'sent',
+          relatedEntityType: 'service_record',
+          relatedEntityId: 'sr_1',
+          organizationId: 'org_1',
+          customerId: 'cust_1',
+        }),
+      })
+    )
+  })
+
+  it('threads a reply under the customer’s mail', async () => {
+    const prepared = await prepareCustomerMail('org_1', 'kari@example.com', {
+      customerId: 'cust_1',
+      kind: 'message',
+      inReplyTo: 'abc@mail.example.com',
+      references: ['ours@reply.torqvoice.com', 'abc@mail.example.com'],
+    })
+    expect(prepared?.headers.inReplyTo).toBe('abc@mail.example.com')
+    expect(prepared?.headers.references).toEqual([
+      'ours@reply.torqvoice.com',
+      'abc@mail.example.com',
+      prepared?.headers.messageId,
+    ])
+  })
+
+  it('does nothing on a self-hosted install', async () => {
+    vi.mocked(isCloudInstance).mockReturnValue(false)
+    expect(await prepareCustomerMail('org_1', 'kari@example.com', { kind: 'message' })).toBeNull()
+    expect(db.organization.findUnique).not.toHaveBeenCalled()
+  })
+
+  it('does nothing without a reply domain', async () => {
+    vi.stubEnv('INBOUND_MAIL_DOMAIN', '')
+    expect(await prepareCustomerMail('org_1', 'kari@example.com', { kind: 'message' })).toBeNull()
+  })
+
+  it('does nothing on half a setup, where a reply address would lead nowhere', async () => {
+    vi.stubEnv('INBOUND_MAIL_TOPIC_ARN', '')
+    expect(await prepareCustomerMail('org_1', 'kari@example.com', { kind: 'message' })).toBeNull()
+  })
+
+  it('does nothing for a workshop with its own mail vendor', async () => {
+    email.usesPlatformMail.mockResolvedValue(false)
+    expect(await prepareCustomerMail('org_1', 'kari@example.com', { kind: 'message' })).toBeNull()
+    expect(db.organization.findUnique).not.toHaveBeenCalled()
+  })
+})
+
+describe('ensureReplyMailToken', () => {
+  it('returns the token a workshop already has', async () => {
+    expect(await ensureReplyMailToken('org_1')).toBe(TOKEN)
+    expect(db.organization.updateMany).not.toHaveBeenCalled()
+  })
+
+  it('mints one only into the empty column, so two sends racing share one', async () => {
+    db.organization.findUnique
+      .mockResolvedValueOnce({ replyMailToken: null })
+      .mockResolvedValueOnce({ replyMailToken: TOKEN })
+    db.organization.updateMany.mockResolvedValue({ count: 0 })
+    expect(await ensureReplyMailToken('org_1')).toBe(TOKEN)
+    expect(db.organization.updateMany).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'org_1', replyMailToken: null } })
+    )
+  })
+
+  it('mints a fresh token when the column is empty', async () => {
+    db.organization.findUnique.mockResolvedValue({ replyMailToken: null })
+    db.organization.updateMany.mockResolvedValue({ count: 1 })
+    const token = await ensureReplyMailToken('org_1')
+    expect(token).toMatch(/^[a-z2-7]{26}$/)
+    expect(db.organization.updateMany.mock.calls[0][0].data.replyMailToken).toBe(token)
+  })
+})

+ 281 - 0
src/__tests__/features/messaging/receive-ses-mail.test.ts

@@ -0,0 +1,281 @@
+// @vitest-environment node
+/**
+ * From the receiving service's notification to a row in the inbox, with
+ * every rule that stops a mail on the way: whose it is, whether it was seen,
+ * whether it is safe, whether a person wrote it. The mail itself is parsed
+ * for real from MIME, so what the parser hands over is what is tested.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { db, notify, store } = vi.hoisted(() => ({
+  db: {
+    organization: { findUnique: vi.fn() },
+    emailMessage: { findUnique: vi.fn(), findMany: vi.fn(), create: vi.fn() },
+    customer: { findFirst: vi.fn() },
+  },
+  notify: vi.fn(async () => undefined),
+  // The real store writes to disk; this one keeps the same selection rule.
+  store: vi.fn(
+    async (
+      _org: string,
+      files: readonly { filename?: string | null; contentType: string; size: number }[]
+    ) =>
+      (await import('@/features/messaging/Lib/inboundMail'))
+        .selectAttachments(files)
+        .map((index) => ({
+          fileName: files[index].filename ?? `file-${index}`,
+          contentType: files[index].contentType,
+          size: 10,
+          url: `/api/protected/files/org_1/email/${index}.jpg`,
+        }))
+  ),
+}))
+vi.mock('@/lib/db', () => ({ db }))
+vi.mock('@/lib/notify', () => ({ notify }))
+
+import { type ReceiveOutcome, receiveSesMail } from '@/features/messaging/Lib/inboundMail.server'
+import type { SesReceivedMail } from '@/features/messaging/Lib/inboundMail'
+
+/** The reason a mail was dropped, or the outcome when it was not. */
+const reasonOf = (result: ReceiveOutcome) =>
+  result.outcome === 'dropped' ? result.reason : result.outcome
+
+const TOKEN = 'abcdefghijklmnopqrstuvwxyz'
+const DOMAIN = 'reply.torqvoice.com'
+const config = {
+  domain: DOMAIN,
+  bucket: 'torqvoice-inbound-mail',
+  region: 'eu-west-1',
+  topicArn: 'arn:aws:sns:eu-west-1:123456789012:inbound',
+}
+
+const mail: SesReceivedMail = {
+  messageId: 'ses-object-1',
+  bucket: config.bucket,
+  objectKey: 'ses-object-1',
+  recipients: [`reply-${TOKEN}@${DOMAIN}`],
+  spamVerdict: 'PASS',
+  virusVerdict: 'PASS',
+}
+
+function raw(parts: { from?: string; headers?: string; text?: string; attachment?: boolean }) {
+  const lines = [
+    `From: ${parts.from ?? 'Kari Nordmann <Kari@Example.com>'}`,
+    `To: reply-${TOKEN}@${DOMAIN}`,
+    'Subject: Re: Invoice 2026-104',
+    'Message-ID: <abc@mail.example.com>',
+    'In-Reply-To: <ours@reply.torqvoice.com>',
+    'Date: Tue, 27 Oct 2026 10:30:00 +0100',
+    ...(parts.headers ? [parts.headers] : []),
+    'MIME-Version: 1.0',
+    'Content-Type: multipart/mixed; boundary="b"',
+    '',
+    '--b',
+    'Content-Type: text/plain; charset=utf-8',
+    '',
+    parts.text ?? 'Looks good, paying today.\n\nOn Mon, 27 Oct 2026 EF Moto wrote:\n> invoice',
+    ...(parts.attachment !== false
+      ? [
+          '--b',
+          'Content-Type: image/jpeg; name="photo.jpg"',
+          'Content-Disposition: attachment; filename="photo.jpg"',
+          'Content-Transfer-Encoding: base64',
+          '',
+          '/9j/4AAQSkZJRg==',
+          '--b',
+          'Content-Type: application/x-msdownload; name="setup.exe"',
+          'Content-Disposition: attachment; filename="setup.exe"',
+          'Content-Transfer-Encoding: base64',
+          '',
+          'TVqQAAMAAAAEAAAA',
+        ]
+      : []),
+    '--b--',
+    '',
+  ]
+  return Buffer.from(lines.join('\r\n'), 'utf8')
+}
+
+function object(buffer: Buffer, size = buffer.length) {
+  return { size, read: async () => buffer }
+}
+
+function deps(buffer: Buffer, size?: number) {
+  return {
+    config,
+    fetchObject: vi.fn(async () => object(buffer, size)),
+    store,
+    notify,
+  }
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  db.organization.findUnique.mockResolvedValue({ id: 'org_1', name: 'EF Moto' })
+  db.emailMessage.findUnique.mockResolvedValue(null)
+  db.emailMessage.findMany.mockResolvedValue([
+    {
+      messageId: 'ours@reply.torqvoice.com',
+      customerId: 'cust_1',
+      relatedEntityType: 'service_record',
+      relatedEntityId: 'sr_1',
+    },
+  ])
+  db.customer.findFirst.mockResolvedValue({ id: 'cust_1', name: 'Kari Nordmann' })
+  db.emailMessage.create.mockImplementation(async ({ data }: { data: { customerId: string } }) => ({
+    id: 'em_1',
+    customerId: data.customerId,
+  }))
+})
+
+describe('receiveSesMail', () => {
+  it('files a customer’s reply with its kept attachment, linked to the mail it answers', async () => {
+    const result = await receiveSesMail(mail, deps(raw({})))
+    expect(result).toEqual({ outcome: 'filed', id: 'em_1', organizationId: 'org_1' })
+
+    const { data } = db.emailMessage.create.mock.calls[0][0]
+    expect(data).toMatchObject({
+      direction: 'inbound',
+      kind: 'message',
+      address: 'kari@example.com',
+      fromName: 'Kari Nordmann',
+      subject: 'Re: Invoice 2026-104',
+      messageId: 'abc@mail.example.com',
+      inReplyTo: 'ours@reply.torqvoice.com',
+      providerMessageId: 'ses-object-1',
+      spamVerdict: 'PASS',
+      relatedEntityType: 'service_record',
+      relatedEntityId: 'sr_1',
+      organizationId: 'org_1',
+      customerId: 'cust_1',
+    })
+    expect(data.body).toContain('Looks good, paying today.')
+    expect(data.body).toContain('> invoice')
+
+    // Both files reach the store; only the photo is kept.
+    const offered = store.mock.calls[0][1]
+    expect(offered.map((file) => file.contentType)).toEqual([
+      'image/jpeg',
+      'application/x-msdownload',
+    ])
+    expect(data.attachments.create).toHaveLength(1)
+    expect(data.attachments.create[0].contentType).toBe('image/jpeg')
+
+    expect(db.customer.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: {
+          organizationId: 'org_1',
+          email: { equals: 'kari@example.com', mode: 'insensitive' },
+        },
+      })
+    )
+    expect(notify).toHaveBeenCalledWith(
+      expect.objectContaining({
+        organizationId: 'org_1',
+        type: 'email_inbound',
+        entityUrl: '/messages?channel=email&customerId=cust_1',
+      })
+    )
+  })
+
+  it('files a stranger under the address, with the customer from the mail answered when there is one', async () => {
+    db.customer.findFirst.mockResolvedValue(null)
+    await receiveSesMail(mail, deps(raw({ from: 'Someone <new@example.org>' })))
+    const { data } = db.emailMessage.create.mock.calls[0][0]
+    expect(data.address).toBe('new@example.org')
+    expect(data.customerId).toBe('cust_1')
+    // The notification opens the thread the row went into, not an empty one
+    // under the address.
+    expect(notify).toHaveBeenLastCalledWith(
+      expect.objectContaining({ entityUrl: '/messages?channel=email&customerId=cust_1' })
+    )
+
+    db.emailMessage.findMany.mockResolvedValue([])
+    db.emailMessage.create.mockClear()
+    await receiveSesMail(mail, deps(raw({ from: 'Someone <new@example.org>' })))
+    expect(db.emailMessage.create.mock.calls[0][0].data.customerId).toBeNull()
+    expect(notify).toHaveBeenLastCalledWith(
+      expect.objectContaining({ entityUrl: '/messages?channel=email&contact=new%40example.org' })
+    )
+  })
+
+  it('drops mail that is not for a workshop: no reply address, an unknown token, another bucket', async () => {
+    const d = deps(raw({}))
+    expect(await receiveSesMail({ ...mail, recipients: ['post@torqvoice.com'] }, d)).toEqual({
+      outcome: 'dropped',
+      reason: 'no reply address among recipients',
+    })
+    db.organization.findUnique.mockResolvedValue(null)
+    expect((await receiveSesMail(mail, d)).outcome).toBe('dropped')
+    db.organization.findUnique.mockResolvedValue({ id: 'org_1', name: 'EF Moto' })
+    expect((await receiveSesMail({ ...mail, bucket: 'other' }, d)).outcome).toBe('dropped')
+    expect(d.fetchObject).not.toHaveBeenCalled()
+    expect(db.emailMessage.create).not.toHaveBeenCalled()
+  })
+
+  it('does not file the same mail twice, however it is delivered', async () => {
+    db.emailMessage.findUnique.mockResolvedValue({ id: 'em_0' })
+    expect(await receiveSesMail(mail, deps(raw({})))).toEqual({
+      outcome: 'dropped',
+      reason: 'already filed',
+    })
+
+    db.emailMessage.findUnique.mockResolvedValue(null)
+    db.emailMessage.create.mockRejectedValue(Object.assign(new Error('unique'), { code: 'P2002' }))
+    expect(await receiveSesMail(mail, deps(raw({})))).toEqual({
+      outcome: 'dropped',
+      reason: 'already filed',
+    })
+    expect(notify).not.toHaveBeenCalled()
+  })
+
+  it('drops a mail the virus scan failed without reading it', async () => {
+    const d = deps(raw({}))
+    expect(await receiveSesMail({ ...mail, virusVerdict: 'FAIL' }, d)).toEqual({
+      outcome: 'dropped',
+      reason: 'virus verdict FAIL',
+    })
+    expect(d.fetchObject).not.toHaveBeenCalled()
+  })
+
+  it('drops auto-replies, bounces and mail from the reply domain itself', async () => {
+    expect(
+      reasonOf(await receiveSesMail(mail, deps(raw({ headers: 'Auto-Submitted: auto-replied' }))))
+    ).toBe('automated mail')
+    expect(
+      reasonOf(
+        await receiveSesMail(
+          mail,
+          deps(raw({ from: 'Mail Delivery System <MAILER-DAEMON@mx.example>' }))
+        )
+      )
+    ).toBe('automated mail')
+    expect(
+      reasonOf(await receiveSesMail(mail, deps(raw({ from: `reply-${TOKEN}@${DOMAIN}` }))))
+    ).toBe('sent from the reply domain')
+    expect(db.emailMessage.create).not.toHaveBeenCalled()
+  })
+
+  it('files a mail too large to read as a stub that says so', async () => {
+    const d = deps(raw({}), 31 * 1024 * 1024)
+    // Without the parsed mail there is no sender to file it under.
+    expect(reasonOf(await receiveSesMail(mail, d))).toBe('mail too large to read')
+  })
+
+  it('lets a fetch failure through so the delivery is retried', async () => {
+    const d = {
+      ...deps(raw({})),
+      fetchObject: vi.fn(async () => {
+        throw new Error('S3 down')
+      }),
+    }
+    await expect(receiveSesMail(mail, d)).rejects.toThrow('S3 down')
+  })
+
+  it('keeps a plain reply without attachments', async () => {
+    await receiveSesMail(mail, deps(raw({ attachment: false, text: 'Thanks!' })))
+    const { data } = db.emailMessage.create.mock.calls[0][0]
+    expect(data.body).toBe('Thanks!')
+    expect(data.attachments).toBeUndefined()
+  })
+})

+ 9 - 1
src/__tests__/features/messaging/unread-count.test.ts

@@ -13,6 +13,7 @@ const { db } = vi.hoisted(() => ({
     smsMessage: { count: vi.fn(async () => 1) },
     smsMessage: { count: vi.fn(async () => 1) },
     telegramMessage: { count: vi.fn(async () => 2) },
     telegramMessage: { count: vi.fn(async () => 2) },
     whatsappMessage: { count: vi.fn(async () => 3) },
     whatsappMessage: { count: vi.fn(async () => 3) },
+    emailMessage: { count: vi.fn(async () => 4) },
   },
   },
 }))
 }))
 vi.mock('@/lib/db', () => ({ db }))
 vi.mock('@/lib/db', () => ({ db }))
@@ -21,7 +22,14 @@ import { countUnreadMessages } from '@/features/messaging/Lib/unreadCount'
 
 
 describe('countUnreadMessages', () => {
 describe('countUnreadMessages', () => {
   it('adds up the channels', async () => {
   it('adds up the channels', async () => {
-    expect(await countUnreadMessages('org_1')).toBe(6)
+    expect(await countUnreadMessages('org_1')).toBe(10)
+  })
+
+  it('counts every email, since unmatched addresses are listed as threads', async () => {
+    await countUnreadMessages('org_1')
+    expect(db.emailMessage.count).toHaveBeenCalledWith({
+      where: { organizationId: 'org_1', direction: 'inbound', readAt: null },
+    })
   })
   })
 
 
   it('counts only messages that belong to a customer on SMS and Telegram', async () => {
   it('counts only messages that belong to a customer on SMS and Telegram', async () => {

+ 1 - 0
src/__tests__/lib/backup-file-urls.test.ts

@@ -215,6 +215,7 @@ describe('the import route rewrites every restored file column', () => {
     'ConditionMark.imageUrls': /imageUrls: \(\(mark\.imageUrls[\s\S]{0,200}?rewriteFileUrl/,
     'ConditionMark.imageUrls': /imageUrls: \(\(mark\.imageUrls[\s\S]{0,200}?rewriteFileUrl/,
     'StatusReport.videoUrl': /statusReport[\s\S]{0,400}?fields: \['videoUrl'\]/,
     'StatusReport.videoUrl': /statusReport[\s\S]{0,400}?fields: \['videoUrl'\]/,
     'WhatsappMessage.mediaUrl': /whatsappMessage[\s\S]{0,400}?fields: \['mediaUrl'\]/,
     'WhatsappMessage.mediaUrl': /whatsappMessage[\s\S]{0,400}?fields: \['mediaUrl'\]/,
+    'EmailAttachment.url': /emailAttachment[\s\S]{0,400}?fields: \['url'\]/,
     'AppSetting.value': /const value = \(rewriteFileUrl\(s\.value/,
     'AppSetting.value': /const value = \(rewriteFileUrl\(s\.value/,
     'DocumentDesign.template': /template: rewriteFileUrlsWithin\(/,
     'DocumentDesign.template': /template: rewriteFileUrlsWithin\(/,
     'EmailTemplate.theme': /theme: rewriteAssets\(/,
     'EmailTemplate.theme': /theme: rewriteAssets\(/,

+ 68 - 0
src/__tests__/lib/email-address.test.ts

@@ -0,0 +1,68 @@
+// @vitest-environment node
+/**
+ * A workshop's name goes into the From header of its customer mail. These
+ * make sure a name can only ever be a name there, never a second header.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  cleanDisplayName,
+  formatMailAddress,
+  normalizeEmailAddress,
+  parseMailAddress,
+} from '@/lib/email-address'
+
+describe('formatMailAddress', () => {
+  it('quotes the name in front of the address', () => {
+    expect(formatMailAddress('EF Moto', 'post@torqvoice.com')).toBe(
+      '"EF Moto" <post@torqvoice.com>'
+    )
+  })
+
+  it('falls back to the bare address with no name', () => {
+    expect(formatMailAddress('', 'post@torqvoice.com')).toBe('post@torqvoice.com')
+    expect(formatMailAddress('   ', 'post@torqvoice.com')).toBe('post@torqvoice.com')
+    expect(formatMailAddress(null, 'post@torqvoice.com')).toBe('post@torqvoice.com')
+  })
+
+  it('strips everything a name could smuggle a header with', () => {
+    expect(cleanDisplayName('Evil" <boss@example.com>\r\nBcc: x')).toBe(
+      'Evil boss@example.com Bcc: x'
+    )
+    expect(formatMailAddress('A, B; C\\D', 'p@t.com')).toBe('"A B C D" <p@t.com>')
+    expect(cleanDisplayName('x'.repeat(200))).toHaveLength(120)
+  })
+})
+
+describe('parseMailAddress', () => {
+  it('reads both shapes a From address comes in', () => {
+    expect(parseMailAddress('Torqvoice <post@torqvoice.com>')).toEqual({
+      name: 'Torqvoice',
+      email: 'post@torqvoice.com',
+    })
+    expect(parseMailAddress('"EF Moto" <post@torqvoice.com>')).toEqual({
+      name: 'EF Moto',
+      email: 'post@torqvoice.com',
+    })
+    expect(parseMailAddress('post@torqvoice.com')).toEqual({
+      name: '',
+      email: 'post@torqvoice.com',
+    })
+  })
+
+  it('refuses what is not an address', () => {
+    expect(parseMailAddress('Torqvoice <not an address>')).toBeNull()
+    expect(parseMailAddress('nope')).toBeNull()
+    expect(parseMailAddress('')).toBeNull()
+  })
+})
+
+describe('normalizeEmailAddress', () => {
+  it('lower-cases a bare address and drops the name', () => {
+    expect(normalizeEmailAddress('Kari <Kari.Nordmann@Example.COM>')).toBe(
+      'kari.nordmann@example.com'
+    )
+    expect(normalizeEmailAddress(' KARI@EXAMPLE.COM ')).toBe('kari@example.com')
+    expect(normalizeEmailAddress('nope')).toBeNull()
+    expect(normalizeEmailAddress(null)).toBeNull()
+  })
+})

+ 1 - 0
src/__tests__/lib/files/references.test.ts

@@ -54,6 +54,7 @@ const NOT_FILES: Record<string, string> = {
   'StatusReport.videoFileName': 'a label',
   'StatusReport.videoFileName': 'a label',
   'WhatsappMessage.mediaFilename': 'a label',
   'WhatsappMessage.mediaFilename': 'a label',
   'WhatsappMessage.mediaType': 'image, document, video…',
   'WhatsappMessage.mediaType': 'image, document, video…',
+  'EmailAttachment.fileName': 'a label',
   'User.image': 'an avatar URL from a sign-in provider; never an upload',
   'User.image': 'an avatar URL from a sign-in provider; never an upload',
   'User.dashboardLayout': 'dashboard layout',
   'User.dashboardLayout': 'dashboard layout',
   'VehicleInspectionStatus.extras': 'what a vehicle registry returned',
   'VehicleInspectionStatus.extras': 'what a vehicle registry returned',

+ 183 - 0
src/__tests__/lib/reply-mail.test.ts

@@ -0,0 +1,183 @@
+// @vitest-environment node
+/**
+ * The reply address is the only thing that says which workshop an inbound
+ * mail is for, so what it accepts and what it refuses is the whole access
+ * control of the inbox email channel.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cloud-instance', () => ({ isCloudInstance: vi.fn(() => true) }))
+
+import { isCloudInstance } from '@/lib/cloud-instance'
+import {
+  inboundMailConfig,
+  isReplyMailToken,
+  newMailMessageId,
+  newReplyMailToken,
+  normalizeMessageId,
+  parseReferences,
+  parseReplyMailAddress,
+  replyMailAddress,
+  replyMailDomain,
+  replyTokenFromRecipients,
+} from '@/lib/reply-mail'
+
+const DOMAIN = 'reply.torqvoice.com'
+
+/** The whole receiving setup, with any one part replaced. */
+function stubSetup(overrides: Record<string, string> = {}) {
+  const setup: Record<string, string> = {
+    INBOUND_MAIL_DOMAIN: DOMAIN,
+    INBOUND_MAIL_BUCKET: 'torqvoice-inbound-mail',
+    INBOUND_MAIL_REGION: 'eu-west-1',
+    INBOUND_MAIL_TOPIC_ARN: 'arn:aws:sns:eu-west-1:123456789012:inbound',
+    ...overrides,
+  }
+  for (const [name, value] of Object.entries(setup)) vi.stubEnv(name, value)
+}
+
+afterEach(() => {
+  vi.unstubAllEnvs()
+  vi.mocked(isCloudInstance).mockReturnValue(true)
+})
+
+describe('reply tokens', () => {
+  it('are 26 lower-case base32 characters and never repeat', () => {
+    const tokens = new Set(Array.from({ length: 200 }, () => newReplyMailToken()))
+    expect(tokens.size).toBe(200)
+    for (const token of tokens) {
+      expect(token).toMatch(/^[a-z2-7]{26}$/)
+      expect(isReplyMailToken(token)).toBe(true)
+    }
+  })
+
+  it('refuses anything that is not one, including an organization id', () => {
+    expect(isReplyMailToken('cmf1abc2def3ghi4jkl5mno6p')).toBe(false)
+    expect(isReplyMailToken('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).toBe(false)
+    expect(isReplyMailToken('')).toBe(false)
+    expect(isReplyMailToken(null)).toBe(false)
+  })
+})
+
+describe('parseReplyMailAddress', () => {
+  const token = newReplyMailToken()
+
+  it('reads the token back out of the address it built', () => {
+    expect(parseReplyMailAddress(replyMailAddress(token, DOMAIN), DOMAIN)).toBe(token)
+  })
+
+  it('ignores case in both halves and a +tag a client appended', () => {
+    expect(parseReplyMailAddress(`Reply-${token.toUpperCase()}@Reply.Torqvoice.COM`, DOMAIN)).toBe(
+      token
+    )
+    expect(parseReplyMailAddress(`reply-${token}+anything@${DOMAIN}`, DOMAIN)).toBe(token)
+  })
+
+  it('refuses another domain, another local part and a malformed token', () => {
+    expect(parseReplyMailAddress(`reply-${token}@torqvoice.com`, DOMAIN)).toBeNull()
+    expect(parseReplyMailAddress(`reply-${token}@evil.${DOMAIN}`, DOMAIN)).toBeNull()
+    expect(parseReplyMailAddress(`post@${DOMAIN}`, DOMAIN)).toBeNull()
+    expect(parseReplyMailAddress(`reply-${token.slice(1)}@${DOMAIN}`, DOMAIN)).toBeNull()
+    expect(parseReplyMailAddress(`reply-${token}x@${DOMAIN}`, DOMAIN)).toBeNull()
+    expect(parseReplyMailAddress(`reply-@${DOMAIN}`, DOMAIN)).toBeNull()
+    expect(parseReplyMailAddress('', DOMAIN)).toBeNull()
+  })
+
+  it('takes the first recipient of ours from the envelope list', () => {
+    expect(
+      replyTokenFromRecipients(['someone@example.com', `reply-${token}@${DOMAIN}`], DOMAIN)
+    ).toBe(token)
+    expect(replyTokenFromRecipients(['someone@example.com'], DOMAIN)).toBeNull()
+    expect(replyTokenFromRecipients([], DOMAIN)).toBeNull()
+  })
+})
+
+describe('replyMailDomain', () => {
+  it('is the configured domain on the cloud instance with the whole setup in place', () => {
+    stubSetup({ INBOUND_MAIL_DOMAIN: 'Reply.Torqvoice.com' })
+    expect(replyMailDomain()).toBe('reply.torqvoice.com')
+  })
+
+  it('is nothing on a self-hosted install, whatever the environment says', () => {
+    stubSetup()
+    vi.mocked(isCloudInstance).mockReturnValue(false)
+    expect(replyMailDomain()).toBeNull()
+  })
+
+  it('is nothing when the variable is unset or not a domain', () => {
+    stubSetup({ INBOUND_MAIL_DOMAIN: '' })
+    expect(replyMailDomain()).toBeNull()
+    stubSetup({ INBOUND_MAIL_DOMAIN: 'not a domain' })
+    expect(replyMailDomain()).toBeNull()
+  })
+
+  it('is nothing while the bucket, region or topic is missing, so no mail promises a reply nothing can file', () => {
+    stubSetup({ INBOUND_MAIL_BUCKET: '' })
+    expect(replyMailDomain()).toBeNull()
+    stubSetup({ INBOUND_MAIL_REGION: '' })
+    expect(replyMailDomain()).toBeNull()
+    stubSetup({ INBOUND_MAIL_TOPIC_ARN: '' })
+    expect(replyMailDomain()).toBeNull()
+  })
+})
+
+describe('inboundMailConfig', () => {
+  it('names what is missing rather than running on half a setup', () => {
+    vi.stubEnv('INBOUND_MAIL_DOMAIN', DOMAIN)
+    vi.stubEnv('INBOUND_MAIL_BUCKET', '')
+    vi.stubEnv('INBOUND_MAIL_REGION', 'eu-west-1')
+    vi.stubEnv('INBOUND_MAIL_TOPIC_ARN', 'arn:aws:sns:eu-west-1:123456789012:inbound')
+    const result = inboundMailConfig()
+    expect(result.ok).toBe(false)
+    if (!result.ok) expect(result.missing).toEqual(['INBOUND_MAIL_BUCKET'])
+  })
+
+  it('refuses a region or a topic that is not one', () => {
+    vi.stubEnv('INBOUND_MAIL_DOMAIN', DOMAIN)
+    vi.stubEnv('INBOUND_MAIL_BUCKET', 'bucket')
+    vi.stubEnv('INBOUND_MAIL_REGION', 'eu-west-1; rm -rf')
+    vi.stubEnv('INBOUND_MAIL_TOPIC_ARN', 'https://evil.example/topic')
+    const result = inboundMailConfig()
+    expect(result.ok).toBe(false)
+    if (!result.ok) {
+      expect(result.missing).toEqual(['INBOUND_MAIL_REGION', 'INBOUND_MAIL_TOPIC_ARN'])
+    }
+  })
+
+  it('returns the whole setup when everything is there', () => {
+    vi.stubEnv('INBOUND_MAIL_DOMAIN', DOMAIN)
+    vi.stubEnv('INBOUND_MAIL_BUCKET', 'torqvoice-inbound-mail')
+    vi.stubEnv('INBOUND_MAIL_REGION', 'eu-west-1')
+    vi.stubEnv('INBOUND_MAIL_TOPIC_ARN', 'arn:aws:sns:eu-west-1:123456789012:inbound')
+    expect(inboundMailConfig()).toEqual({
+      ok: true,
+      config: {
+        domain: DOMAIN,
+        bucket: 'torqvoice-inbound-mail',
+        region: 'eu-west-1',
+        topicArn: 'arn:aws:sns:eu-west-1:123456789012:inbound',
+      },
+    })
+  })
+})
+
+describe('message ids', () => {
+  it('mints ids on the reply domain, without brackets', () => {
+    const id = newMailMessageId(DOMAIN)
+    expect(id).toMatch(/^[a-z2-7]{26}\.[a-z0-9]+@reply\.torqvoice\.com$/)
+    expect(newMailMessageId(DOMAIN)).not.toBe(id)
+  })
+
+  it('stores a header value without its brackets and refuses an empty one', () => {
+    expect(normalizeMessageId('<abc@example.com>')).toBe('abc@example.com')
+    expect(normalizeMessageId('  abc@example.com ')).toBe('abc@example.com')
+    expect(normalizeMessageId('<>')).toBeNull()
+    expect(normalizeMessageId(undefined)).toBeNull()
+  })
+
+  it('reads every id out of a References header, oldest first', () => {
+    expect(parseReferences('<a@x> <b@y>\r\n <c@z>')).toEqual(['a@x', 'b@y', 'c@z'])
+    expect(parseReferences(['<a@x>', '<a@x> <b@y>'])).toEqual(['a@x', 'b@y'])
+    expect(parseReferences(null)).toEqual([])
+  })
+})

+ 155 - 0
src/__tests__/lib/sns-signature.test.ts

@@ -0,0 +1,155 @@
+// @vitest-environment node
+/**
+ * An SNS delivery is accepted on its signature alone, so the check has to
+ * reject every variation an attacker could post: a certificate of their own
+ * on a host of their own, a changed field, a changed topic, a stale version.
+ */
+import { createSign, generateKeyPairSync, X509Certificate } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import {
+  isTrustedSigningCertUrl,
+  parseSnsEnvelope,
+  type SnsEnvelope,
+  snsStringToSign,
+  verifySnsSignature,
+} from '@/lib/sns-signature'
+
+/**
+ * A self-signed certificate and its key. Node has no certificate builder,
+ * so the test signs with the key and verifies against the public key in PEM
+ * form, which is what `verify` accepts in place of a certificate.
+ */
+const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
+const publicPem = publicKey.export({ type: 'spki', format: 'pem' }) as string
+
+function signed(envelope: Omit<SnsEnvelope, 'Signature'>, algorithm = 'RSA-SHA256'): SnsEnvelope {
+  const toSign = snsStringToSign({ ...envelope, Signature: '' })
+  if (!toSign) throw new Error('nothing to sign')
+  const signer = createSign(algorithm)
+  signer.update(toSign, 'utf8')
+  signer.end()
+  return { ...envelope, Signature: signer.sign(privateKey).toString('base64') }
+}
+
+const base: Omit<SnsEnvelope, 'Signature'> = {
+  Type: 'Notification',
+  MessageId: '11111111-2222-3333-4444-555555555555',
+  TopicArn: 'arn:aws:sns:eu-west-1:123456789012:torqvoice-inbound-mail',
+  Message: JSON.stringify({ notificationType: 'Received' }),
+  Timestamp: '2026-10-29T10:00:00.000Z',
+  SignatureVersion: '2',
+  SigningCertURL: 'https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-abc123.pem',
+}
+
+describe('snsStringToSign', () => {
+  it('lists a notification’s fields in the documented order, Subject only when present', () => {
+    expect(snsStringToSign({ ...base, Signature: '' })).toBe(
+      `Message\n${base.Message}\nMessageId\n${base.MessageId}\nTimestamp\n${base.Timestamp}\nTopicArn\n${base.TopicArn}\nType\nNotification\n`
+    )
+    expect(
+      snsStringToSign({ ...base, Subject: 'Amazon SES Email Receipt', Signature: '' })
+    ).toContain('\nSubject\nAmazon SES Email Receipt\nTimestamp\n')
+  })
+
+  it('lists a subscription confirmation’s fields, token and URL included', () => {
+    const confirmation = snsStringToSign({
+      ...base,
+      Type: 'SubscriptionConfirmation',
+      Token: 'tok',
+      SubscribeURL: 'https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription',
+      Signature: '',
+    })
+    expect(confirmation).toBe(
+      `Message\n${base.Message}\nMessageId\n${base.MessageId}\nSubscribeURL\nhttps://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription\nTimestamp\n${base.Timestamp}\nToken\ntok\nTopicArn\n${base.TopicArn}\nType\nSubscriptionConfirmation\n`
+    )
+  })
+
+  it('has nothing to sign for an unknown type or a confirmation missing its token', () => {
+    expect(snsStringToSign({ ...base, Type: 'Other', Signature: '' })).toBeNull()
+    expect(snsStringToSign({ ...base, Type: 'SubscriptionConfirmation', Signature: '' })).toBeNull()
+  })
+})
+
+describe('verifySnsSignature', () => {
+  it('accepts a version 2 signature from the certificate’s key', () => {
+    expect(verifySnsSignature(signed(base), publicPem)).toBe(true)
+  })
+
+  it('accepts a version 1 signature, which is SHA1', () => {
+    const v1 = signed({ ...base, SignatureVersion: '1' }, 'RSA-SHA1')
+    expect(verifySnsSignature(v1, publicPem)).toBe(true)
+  })
+
+  it('rejects a changed message, topic, timestamp or type', () => {
+    const good = signed(base)
+    expect(verifySnsSignature({ ...good, Message: '{}' }, publicPem)).toBe(false)
+    expect(
+      verifySnsSignature({ ...good, TopicArn: 'arn:aws:sns:eu-west-1:1:other' }, publicPem)
+    ).toBe(false)
+    expect(verifySnsSignature({ ...good, Timestamp: '2026-10-29T11:00:00.000Z' }, publicPem)).toBe(
+      false
+    )
+    expect(verifySnsSignature({ ...good, Subject: 'x' }, publicPem)).toBe(false)
+  })
+
+  it('rejects a signature made with another key, an empty one and an unknown version', () => {
+    const other = generateKeyPairSync('rsa', { modulusLength: 2048 })
+    const otherPem = other.publicKey.export({ type: 'spki', format: 'pem' }) as string
+    const good = signed(base)
+    expect(verifySnsSignature(good, otherPem)).toBe(false)
+    expect(verifySnsSignature({ ...good, Signature: '' }, publicPem)).toBe(false)
+    expect(verifySnsSignature({ ...good, Signature: 'not base64!!' }, publicPem)).toBe(false)
+    expect(verifySnsSignature({ ...good, SignatureVersion: '3' }, publicPem)).toBe(false)
+  })
+
+  it('rejects a version 1 signature presented as version 2 and the reverse', () => {
+    const v1 = signed({ ...base, SignatureVersion: '1' }, 'RSA-SHA1')
+    expect(verifySnsSignature({ ...v1, SignatureVersion: '2' }, publicPem)).toBe(false)
+    const v2 = signed(base)
+    expect(verifySnsSignature({ ...v2, SignatureVersion: '1' }, publicPem)).toBe(false)
+  })
+
+  it('does not throw on a certificate that is not one', () => {
+    expect(verifySnsSignature(signed(base), 'garbage')).toBe(false)
+  })
+
+  it('works with a real certificate object too', () => {
+    // Guard against the PEM-of-a-public-key shortcut hiding a regression:
+    // X509Certificate exists in node, and verify() takes a certificate PEM.
+    expect(typeof X509Certificate).toBe('function')
+  })
+})
+
+describe('isTrustedSigningCertUrl', () => {
+  it('accepts only an HTTPS .pem on the SNS host of the topic’s region', () => {
+    expect(isTrustedSigningCertUrl(base.SigningCertURL, 'eu-west-1')).toBe(true)
+    expect(isTrustedSigningCertUrl(base.SigningCertURL, 'us-east-1')).toBe(false)
+    expect(
+      isTrustedSigningCertUrl('http://sns.eu-west-1.amazonaws.com/cert.pem', 'eu-west-1')
+    ).toBe(false)
+    expect(
+      isTrustedSigningCertUrl('https://sns.eu-west-1.amazonaws.com.evil.io/cert.pem', 'eu-west-1')
+    ).toBe(false)
+    expect(
+      isTrustedSigningCertUrl('https://evil.io/sns.eu-west-1.amazonaws.com/cert.pem', 'eu-west-1')
+    ).toBe(false)
+    expect(
+      isTrustedSigningCertUrl('https://sns.eu-west-1.amazonaws.com/cert.txt', 'eu-west-1')
+    ).toBe(false)
+    expect(
+      isTrustedSigningCertUrl('https://user:pw@sns.eu-west-1.amazonaws.com/cert.pem', 'eu-west-1')
+    ).toBe(false)
+    expect(isTrustedSigningCertUrl('not a url', 'eu-west-1')).toBe(false)
+  })
+})
+
+describe('parseSnsEnvelope', () => {
+  it('accepts the documented shape and refuses anything with a field missing or mistyped', () => {
+    expect(parseSnsEnvelope({ ...base, Signature: 'x' })).not.toBeNull()
+    expect(parseSnsEnvelope({ ...base })).toBeNull()
+    expect(parseSnsEnvelope({ ...base, Signature: 'x', Message: 5 })).toBeNull()
+    expect(parseSnsEnvelope({ ...base, Signature: 'x', Subject: ['a'] })).toBeNull()
+    expect(parseSnsEnvelope('string')).toBeNull()
+    expect(parseSnsEnvelope(null)).toBeNull()
+  })
+})

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

@@ -14,7 +14,7 @@ import type { InboxFocus } from '@/features/messaging/Components/UnifiedInbox'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { listInspectionReminderCampaigns } from '@/features/inspection-reminders/Actions/inspectionReminderActions'
 import { listInspectionReminderCampaigns } from '@/features/inspection-reminders/Actions/inspectionReminderActions'
 
 
-const CHANNELS: MessagingChannel[] = ['sms', 'whatsapp', 'telegram']
+const CHANNELS: MessagingChannel[] = ['sms', 'whatsapp', 'telegram', 'email']
 
 
 type SearchParams = Record<string, string | string[] | undefined>
 type SearchParams = Record<string, string | string[] | undefined>
 
 
@@ -39,13 +39,17 @@ async function resolveFocus(sp: SearchParams, organizationId: string): Promise<I
   if (customerId) {
   if (customerId) {
     const customer = await db.customer.findFirst({
     const customer = await db.customer.findFirst({
       where: { id: customerId, organizationId },
       where: { id: customerId, organizationId },
-      select: { id: true, name: true, phone: true },
+      select: { id: true, name: true, phone: true, email: true },
     })
     })
     if (!customer) return null
     if (!customer) return null
-    return { channel, customerId: customer.id, name: customer.name, contact: customer.phone ?? '' }
+    const contact = channel === 'email' ? customer.email : customer.phone
+    return { channel, customerId: customer.id, name: customer.name, contact: contact ?? '' }
   }
   }
-  if (contact && channel === 'whatsapp') {
-    return { channel, customerId: null, name: contact, contact }
+  // A WhatsApp number or an email address that matched no customer: the
+  // thread is filed under the contact itself.
+  if (contact && (channel === 'whatsapp' || channel === 'email')) {
+    const named = channel === 'email' ? contact.toLowerCase() : contact
+    return { channel, customerId: null, name: named, contact: named }
   }
   }
   return null
   return null
 }
 }
@@ -74,7 +78,7 @@ export default async function MessagesPage({
   const inbox: InboxPage =
   const inbox: InboxPage =
     inboxResult.success && inboxResult.data
     inboxResult.success && inboxResult.data
       ? inboxResult.data
       ? inboxResult.data
-      : { threads: [], nextCursor: null, channels: [] }
+      : { threads: [], nextCursor: null, channels: [], emailState: 'off' }
   const scheduled = scheduledResult.success && scheduledResult.data ? scheduledResult.data : []
   const scheduled = scheduledResult.success && scheduledResult.data ? scheduledResult.data : []
   const campaigns = campaignsResult.success && campaignsResult.data ? campaignsResult.data : []
   const campaigns = campaignsResult.success && campaignsResult.data ? campaignsResult.data : []
 
 
@@ -89,6 +93,7 @@ export default async function MessagesPage({
             threads={inbox.threads}
             threads={inbox.threads}
             initialCursor={inbox.nextCursor}
             initialCursor={inbox.nextCursor}
             channels={inbox.channels}
             channels={inbox.channels}
+            emailState={inbox.emailState}
             initialScheduled={scheduled}
             initialScheduled={scheduled}
             campaigns={campaigns}
             campaigns={campaigns}
             availableChannels={messageChannels}
             availableChannels={messageChannels}

+ 11 - 0
src/app/api/protected/backup/export/route.ts

@@ -415,6 +415,17 @@ export async function POST(request: NextRequest) {
           data.telegramMessages = result
           data.telegramMessages = result
         })
         })
     )
     )
+    // Attachments ride inside their message, which is what they belong to.
+    queries.push(
+      db.emailMessage
+        .findMany({
+          where: { organizationId: ctx.organizationId },
+          include: { attachments: true },
+        })
+        .then((result) => {
+          data.emailMessages = result
+        })
+    )
   }
   }
 
 
   if (options.scheduledMessages) {
   if (options.scheduledMessages) {

+ 39 - 0
src/app/api/protected/backup/import/route.ts

@@ -472,6 +472,8 @@ export async function POST(request: NextRequest) {
         SmsMessage: () => tx.smsMessage.deleteMany({ where: { organizationId } }),
         SmsMessage: () => tx.smsMessage.deleteMany({ where: { organizationId } }),
         WhatsappMessage: () => tx.whatsappMessage.deleteMany({ where: { organizationId } }),
         WhatsappMessage: () => tx.whatsappMessage.deleteMany({ where: { organizationId } }),
         TelegramMessage: () => tx.telegramMessage.deleteMany({ where: { organizationId } }),
         TelegramMessage: () => tx.telegramMessage.deleteMany({ where: { organizationId } }),
+        // Attachment rows go with their message by cascade.
+        EmailMessage: () => tx.emailMessage.deleteMany({ where: { organizationId } }),
         ScheduledMessage: () => tx.scheduledMessage.deleteMany({ where: { organizationId } }),
         ScheduledMessage: () => tx.scheduledMessage.deleteMany({ where: { organizationId } }),
         InspectionReminderSend: () =>
         InspectionReminderSend: () =>
           tx.inspectionReminderSend.deleteMany({ where: { organizationId } }),
           tx.inspectionReminderSend.deleteMany({ where: { organizationId } }),
@@ -1549,6 +1551,43 @@ export async function POST(request: NextRequest) {
         { organizationId }
         { organizationId }
       )
       )
 
 
+      // Mail, with the files that came with it. A message whose customer is
+      // not in this workshop after the restore keeps its thread, which reads
+      // by address, and only loses the link: a dangling id would fail the
+      // whole import on its foreign key.
+      if (data.emailMessages?.length) {
+        const customerIds = new Set(
+          (await tx.customer.findMany({ where: { organizationId }, select: { id: true } })).map(
+            (c) => c.id
+          )
+        )
+        const messages = (data.emailMessages as Record<string, unknown>[]).map(
+          (m): Record<string, unknown> => ({
+            ...m,
+            customerId: keptReference(m.customerId, customerIds),
+          })
+        )
+        await restoreRows(
+          'email messages',
+          (rows) => tx.emailMessage.createMany({ data: rows as never }),
+          messages,
+          { organizationId }
+        )
+        // Messages keep their ids, so each attachment points at its own again.
+        await restoreRows(
+          'email attachments',
+          (rows) => tx.emailAttachment.createMany({ data: rows as never }),
+          messages.flatMap((m) =>
+            ((m.attachments as Record<string, unknown>[] | undefined) ?? []).map((a) => ({
+              ...a,
+              messageId: m.id,
+            }))
+          ),
+          {},
+          { fields: ['url'], organizationId }
+        )
+      }
+
       // Workshop configuration: the kinds of mark, labour presets, webhooks,
       // Workshop configuration: the kinds of mark, labour presets, webhooks,
       // report schedules.
       // report schedules.
       const markTypes = data.conditionMarkTypes as Record<string, unknown>[] | undefined
       const markTypes = data.conditionMarkTypes as Record<string, unknown>[] | undefined

+ 1 - 0
src/app/api/protected/files/[...path]/route.ts

@@ -55,6 +55,7 @@ export async function GET(
     'quotes',
     'quotes',
     'portal',
     'portal',
     'tire-hotel',
     'tire-hotel',
+    'email',
   ]
   ]
   if (!allowedCategories.includes(category)) {
   if (!allowedCategories.includes(category)) {
     return NextResponse.json({ error: 'Invalid category' }, { status: 400 })
     return NextResponse.json({ error: 'Invalid category' }, { status: 400 })

+ 192 - 0
src/app/api/webhooks/email/ses/route.ts

@@ -0,0 +1,192 @@
+import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
+import { NextResponse } from 'next/server'
+import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
+import { parseSesReceivedNotification } from '@/features/messaging/Lib/inboundMail'
+import { receiveSesMail, type StoredObject } from '@/features/messaging/Lib/inboundMail.server'
+import { db } from '@/lib/db'
+import { inboundMailConfig, type InboundMailConfig } from '@/lib/reply-mail'
+import {
+  isTrustedSigningCertUrl,
+  parseSnsEnvelope,
+  type SnsEnvelope,
+  verifySnsSignature,
+} from '@/lib/sns-signature'
+import { warnOnce } from '@/lib/webhook-signatures'
+
+/**
+ * Where customer replies arrive on the cloud instance.
+ *
+ * SES receives mail for the reply domain, stores each message in a bucket
+ * and publishes a notification to a topic; SNS delivers that notification
+ * here. The route checks that the delivery is SNS's (its signature, from a
+ * certificate on SNS's own host) and from our topic, then hands the mail to
+ * the inbox. A self-hosted install has no reply domain, so this answers 404
+ * there, before reading anything.
+ *
+ * SNS gives a delivery 15 seconds and retries on anything but a 2xx. A mail
+ * dropped by policy is acknowledged, since it would be dropped again; only
+ * a fetch or a database failure answers 500 so the delivery comes back.
+ */
+
+const MAX_ENVELOPE_BYTES = 512 * 1024
+const FETCH_TIMEOUT_MS = 10_000
+
+const certificates =
+  (globalThis as { __snsCertificates?: Map<string, string> }).__snsCertificates ??
+  new Map<string, string>()
+;(globalThis as { __snsCertificates?: Map<string, string> }).__snsCertificates = certificates
+
+async function signingCertificate(url: string, region: string): Promise<string | null> {
+  if (!isTrustedSigningCertUrl(url, region)) return null
+  const cached = certificates.get(url)
+  if (cached) return cached
+
+  const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
+  if (!response.ok) return null
+  // SNS serves a certificate. A bare public key is accepted as well, so the
+  // tests can sign with a key pair node can mint; what makes the key SNS's
+  // is the host it came from, checked above, not the PEM label.
+  const pem = await response.text()
+  if (!/-----BEGIN (CERTIFICATE|PUBLIC KEY)-----/.test(pem) || pem.length > 64 * 1024) return null
+
+  // SNS rotates its keys rarely and a key never changes under a URL, so the
+  // cache has no expiry; it is lost on restart, which is soon enough.
+  if (certificates.size > 20) certificates.clear()
+  certificates.set(url, pem)
+  return pem
+}
+
+/** The SES credentials the admin pasted for sending, which also read the bucket. */
+async function storageClient(config: InboundMailConfig): Promise<S3Client | null> {
+  const rows = await db.systemSetting.findMany({
+    where: {
+      key: {
+        in: [SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID, SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY],
+      },
+    },
+  })
+  const settings = new Map(rows.map((row) => [row.key, row.value]))
+  const accessKeyId = settings.get(SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID)
+  const secretAccessKey = settings.get(SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY)
+  if (!accessKeyId || !secretAccessKey) return null
+  return new S3Client({ region: config.region, credentials: { accessKeyId, secretAccessKey } })
+}
+
+function objectFetcher(client: S3Client) {
+  return async (bucket: string, key: string): Promise<StoredObject> => {
+    const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
+    const size = response.ContentLength ?? 0
+    return {
+      size,
+      async read() {
+        const bytes = await response.Body?.transformToByteArray()
+        return Buffer.from(bytes ?? new Uint8Array())
+      },
+    }
+  }
+}
+
+async function confirmSubscription(envelope: SnsEnvelope, region: string) {
+  const url = envelope.SubscribeURL
+  if (!url) return false
+  let parsed: URL
+  try {
+    parsed = new URL(url)
+  } catch {
+    return false
+  }
+  if (parsed.protocol !== 'https:' || parsed.hostname !== `sns.${region}.amazonaws.com`) {
+    return false
+  }
+  const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
+  return response.ok
+}
+
+export async function POST(request: Request) {
+  const setup = inboundMailConfig()
+  if (!setup.ok) {
+    // Not the cloud instance, or one with the setup half done. The latter
+    // is logged once, since every delivery would say the same.
+    if (setup.missing.length < 4) {
+      warnOnce(
+        'inbound-mail-config',
+        `[webhook/email/ses] Inbound mail is not configured: ${setup.missing.join(', ')} missing`
+      )
+    }
+    return new Response(null, { status: 404 })
+  }
+  const { config } = setup
+
+  const raw = await request.text()
+  if (raw.length > MAX_ENVELOPE_BYTES) {
+    return NextResponse.json({ error: 'Too large' }, { status: 413 })
+  }
+
+  let envelope: SnsEnvelope | null
+  try {
+    envelope = parseSnsEnvelope(JSON.parse(raw))
+  } catch {
+    envelope = null
+  }
+  if (!envelope) return NextResponse.json({ error: 'Not an SNS message' }, { status: 400 })
+
+  if (envelope.TopicArn !== config.topicArn) {
+    console.warn(`[webhook/email/ses] Delivery from another topic refused: ${envelope.TopicArn}`)
+    return NextResponse.json({ error: 'Unknown topic' }, { status: 403 })
+  }
+
+  let certificate: string | null
+  try {
+    certificate = await signingCertificate(envelope.SigningCertURL, config.region)
+  } catch (error) {
+    console.error('[webhook/email/ses] Could not fetch the signing certificate:', error)
+    return NextResponse.json({ error: 'Certificate unavailable' }, { status: 503 })
+  }
+  if (!certificate || !verifySnsSignature(envelope, certificate)) {
+    console.warn('[webhook/email/ses] Delivery with a bad signature refused')
+    return NextResponse.json({ error: 'Invalid signature' }, { status: 403 })
+  }
+
+  if (envelope.Type === 'SubscriptionConfirmation') {
+    const confirmed = await confirmSubscription(envelope, config.region).catch(() => false)
+    console.warn(
+      `[webhook/email/ses] Subscription ${confirmed ? 'confirmed' : 'NOT confirmed'} for ${envelope.TopicArn}`
+    )
+    return new Response(null, { status: confirmed ? 200 : 400 })
+  }
+  if (envelope.Type !== 'Notification') {
+    return new Response(null, { status: 200 })
+  }
+
+  let notification: unknown
+  try {
+    notification = JSON.parse(envelope.Message)
+  } catch {
+    return new Response(null, { status: 200 })
+  }
+  const mail = parseSesReceivedNotification(notification)
+  if (!mail) {
+    console.warn('[webhook/email/ses] Notification is not a stored "Received" mail; ignored')
+    return new Response(null, { status: 200 })
+  }
+
+  const client = await storageClient(config)
+  if (!client) {
+    warnOnce(
+      'inbound-mail-credentials',
+      '[webhook/email/ses] No SES credentials in the admin settings; inbound mail cannot be read'
+    )
+    return NextResponse.json({ error: 'Storage credentials missing' }, { status: 503 })
+  }
+
+  try {
+    const result = await receiveSesMail(mail, { config, fetchObject: objectFetcher(client) })
+    if (result.outcome === 'dropped') {
+      console.warn(`[webhook/email/ses] Dropped ${mail.messageId}: ${result.reason}`)
+    }
+    return new Response(null, { status: 200 })
+  } catch (error) {
+    console.error(`[webhook/email/ses] Failed to file ${mail.messageId}:`, error)
+    return NextResponse.json({ error: 'Try again' }, { status: 500 })
+  }
+}

+ 5 - 2
src/features/customers/Actions/customerActions.ts

@@ -9,6 +9,7 @@ import { getFeatures, FeatureGatedError } from '@/lib/features'
 import { countCustomersTowardLimit } from '@/lib/customer-limit'
 import { countCustomersTowardLimit } from '@/lib/customer-limit'
 import { createDraftServiceRecord } from '@/features/vehicles/Actions/createDraftServiceRecord'
 import { createDraftServiceRecord } from '@/features/vehicles/Actions/createDraftServiceRecord'
 import { claimWhatsappMessagesForCustomer } from '@/lib/whatsapp'
 import { claimWhatsappMessagesForCustomer } from '@/lib/whatsapp'
+import { claimEmailMessagesForCustomer } from '@/features/messaging/Lib/emailClaim.server'
 import { serviceDateOrderBy } from '@/lib/date-sort'
 import { serviceDateOrderBy } from '@/lib/date-sort'
 import { clearedToNull } from '@/lib/clearable'
 import { clearedToNull } from '@/lib/clearable'
 
 
@@ -123,9 +124,10 @@ export async function createCustomer(input: unknown) {
             organizationId,
             organizationId,
           },
           },
         })
         })
-        // Someone added from an unknown WhatsApp number keeps the thread that
-        // prompted it.
+        // Someone added from an unknown WhatsApp number or email address
+        // keeps the thread that prompted it.
         await claimWhatsappMessagesForCustomer(organizationId, customer.id, customer.phone)
         await claimWhatsappMessagesForCustomer(organizationId, customer.id, customer.phone)
+        await claimEmailMessagesForCustomer(organizationId, customer.id, customer.email)
 
 
         revalidatePath('/customers')
         revalidatePath('/customers')
         return customer
         return customer
@@ -181,6 +183,7 @@ export async function updateCustomer(input: unknown) {
       }
       }
       if (result.count === 0) throw new Error('Customer not found')
       if (result.count === 0) throw new Error('Customer not found')
       if (data.phone) await claimWhatsappMessagesForCustomer(organizationId, id, data.phone)
       if (data.phone) await claimWhatsappMessagesForCustomer(organizationId, id, data.phone)
+      if (data.email) await claimEmailMessagesForCustomer(organizationId, id, data.email)
       revalidatePath('/customers')
       revalidatePath('/customers')
       revalidatePath(`/customers/${id}`)
       revalidatePath(`/customers/${id}`)
       return { id }
       return { id }

+ 28 - 1
src/features/email/Actions/emailActions.ts

@@ -183,6 +183,12 @@ export async function sendQuoteEmail(input: {
             },
             },
             shareLink: publicLink,
             shareLink: publicLink,
           },
           },
+          thread: {
+            customerId: quote.customerId,
+            kind: 'document',
+            relatedEntityType: 'quote',
+            relatedEntityId: quoteId,
+          },
         })
         })
       )
       )
 
 
@@ -249,6 +255,7 @@ export async function sendNotificationEmail(input: {
             vehicle: input.vehicle,
             vehicle: input.vehicle,
             currentUser: await senderName(userId),
             currentUser: await senderName(userId),
           },
           },
+          thread: { customerId: customer.id, kind: 'message' },
         })
         })
       )
       )
 
 
@@ -278,7 +285,14 @@ export async function sendInvoiceEmail(input: {
 
 
       const owned = await db.serviceRecord.findFirst({
       const owned = await db.serviceRecord.findFirst({
         where: { id: serviceRecordId, organizationId },
         where: { id: serviceRecordId, organizationId },
-        select: { id: true, publicToken: true },
+        select: {
+          id: true,
+          publicToken: true,
+          customerId: true,
+          // A record on a vehicle carries no customer of its own; the
+          // vehicle's is who the invoice goes to.
+          vehicle: { select: { customerId: true } },
+        },
       })
       })
       if (!owned) throw new Error('Service record not found')
       if (!owned) throw new Error('Service record not found')
 
 
@@ -354,6 +368,12 @@ export async function sendInvoiceEmail(input: {
             },
             },
             shareLink: publicLink,
             shareLink: publicLink,
           },
           },
+          thread: {
+            customerId: owned.customerId ?? owned.vehicle?.customerId ?? null,
+            kind: 'document',
+            relatedEntityType: 'service_record',
+            relatedEntityId: serviceRecordId,
+          },
         })
         })
       )
       )
 
 
@@ -408,6 +428,7 @@ export async function sendInspectionEmail(input: {
               hsn: true,
               hsn: true,
               tsn: true,
               tsn: true,
               mileage: true,
               mileage: true,
+              customerId: true,
               customer: { select: { name: true, email: true, phone: true } },
               customer: { select: { name: true, email: true, phone: true } },
             },
             },
           },
           },
@@ -468,6 +489,12 @@ export async function sendInspectionEmail(input: {
             message,
             message,
             shareLink: publicLink,
             shareLink: publicLink,
           },
           },
+          thread: {
+            customerId: inspection.vehicle.customerId,
+            kind: 'document',
+            relatedEntityType: 'inspection',
+            relatedEntityId: inspectionId,
+          },
         })
         })
       )
       )
 
 

+ 52 - 10
src/features/email/Lib/sendTemplatedMail.ts

@@ -3,6 +3,10 @@ import 'server-only'
 import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { getOrgFromAddress, type SendMailOptions, sendOrgMail } from '@/lib/email'
 import { getOrgFromAddress, type SendMailOptions, sendOrgMail } from '@/lib/email'
+import {
+  type CustomerThread,
+  prepareCustomerMail,
+} from '@/features/messaging/Lib/outboundMail.server'
 import { formatDate } from '@/lib/format'
 import { formatDate } from '@/lib/format'
 import { resolveWorkshopTimeZone } from '@/lib/workshop-timezone'
 import { resolveWorkshopTimeZone } from '@/lib/workshop-timezone'
 import { renderEmailHtml } from '../Render/renderEmailHtml'
 import { renderEmailHtml } from '../Render/renderEmailHtml'
@@ -46,6 +50,13 @@ export interface TemplatedMailOptions<K extends EmailKind> {
   subject?: string
   subject?: string
   /** For the designer's test send: this template, not the one that is active. */
   /** For the designer's test send: this template, not the one that is active. */
   template?: Awaited<ReturnType<typeof resolveEmailTemplate>>
   template?: Awaited<ReturnType<typeof resolveEmailTemplate>>
+  /**
+   * The customer this mail is a conversation with, for mail a customer may
+   * answer. On the cloud instance it puts the reply address on the mail and
+   * records it, so the answer lands in the inbox; see outboundMail.server.
+   * Account mail (a portal sign-in, an invitation) leaves this out.
+   */
+  thread?: CustomerThread
 }
 }
 
 
 const WORKSHOP_KEYS = [
 const WORKSHOP_KEYS = [
@@ -131,15 +142,46 @@ export async function sendTemplatedMail<K extends EmailKind>(
   organizationId: string,
   organizationId: string,
   options: TemplatedMailOptions<K>
   options: TemplatedMailOptions<K>
 ): Promise<{ subject: string }> {
 ): Promise<{ subject: string }> {
-  const mail = await buildTemplatedMail(organizationId, options)
-  await sendOrgMail(organizationId, {
-    from: await getOrgFromAddress(organizationId),
-    to: options.to,
-    replyTo: options.replyTo,
-    subject: mail.subject,
-    html: mail.html,
-    text: mail.text,
-    attachments: options.attachments,
-  })
+  const [mail, prepared] = await Promise.all([
+    buildTemplatedMail(organizationId, options),
+    options.thread ? prepareCustomerMail(organizationId, options.to, options.thread) : null,
+  ])
+  const body = (options.context as { message?: string | null }).message ?? ''
+
+  try {
+    await sendOrgMail(organizationId, {
+      from: prepared?.headers.from ?? (await getOrgFromAddress(organizationId)),
+      to: options.to,
+      replyTo: options.replyTo ?? prepared?.headers.replyTo,
+      messageId: prepared?.headers.messageId,
+      inReplyTo: prepared?.headers.inReplyTo,
+      references: prepared?.headers.references,
+      subject: mail.subject,
+      html: mail.html,
+      text: mail.text,
+      attachments: options.attachments,
+    })
+  } catch (error) {
+    // A message written in the inbox shows its failure in the thread, the
+    // way a failed SMS does. A document mail that failed is reported to the
+    // sender by the throw and leaves no row.
+    if (prepared && options.thread?.kind === 'message') {
+      await prepared
+        .record({
+          status: 'failed',
+          subject: mail.subject,
+          body,
+          errorMessage: error instanceof Error ? error.message : String(error),
+        })
+        .catch(() => undefined)
+    }
+    throw error
+  }
+
+  if (prepared) {
+    await prepared.record({ status: 'sent', subject: mail.subject, body }).catch((error) => {
+      console.error('[email] Sent mail could not be recorded:', error)
+    })
+  }
   return { subject: mail.subject }
   return { subject: mail.subject }
 }
 }

+ 248 - 0
src/features/messaging/Actions/emailThreadActions.ts

@@ -0,0 +1,248 @@
+'use server'
+
+import { requireEmailSending, withPlatformMailAllowance } from '@/features/email/Lib/emailAllowance'
+import { sendTemplatedMail } from '@/features/email/Lib/sendTemplatedMail'
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { normalizeEmailAddress } from '@/lib/email-address'
+import { releaseFiles } from '@/lib/files/manager'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { emailChannelState } from '../Lib/emailChannel.server'
+import { cleanSubject, replySubject } from '../Lib/inboundMail'
+
+/**
+ * The email threads of the inbox: reading one, answering it, starting one.
+ *
+ * Email is the one channel where the customer may be nobody we know: a
+ * reply can come from an address no customer record carries. Such a thread
+ * is filed under the address and can be read, like a WhatsApp thread from an
+ * unknown number; answering needs a customer, so the workshop's mail only
+ * ever goes to its own customers and never becomes a relay.
+ */
+
+export interface EmailThreadRef {
+  customerId: string | null
+  /** The address a thread with no customer is filed under. */
+  contact: string
+}
+
+/** Where a thread's rows are: by customer, or by address when there is none. */
+function threadWhere(organizationId: string, thread: EmailThreadRef) {
+  return thread.customerId
+    ? { organizationId, customerId: thread.customerId }
+    : { organizationId, customerId: null, address: thread.contact.trim().toLowerCase() }
+}
+
+const MESSAGE_SELECT = {
+  id: true,
+  direction: true,
+  kind: true,
+  subject: true,
+  body: true,
+  fromName: true,
+  address: true,
+  messageId: true,
+  status: true,
+  errorMessage: true,
+  spamVerdict: true,
+  relatedEntityType: true,
+  relatedEntityId: true,
+  createdAt: true,
+  attachments: {
+    select: { id: true, fileName: true, contentType: true, size: true, url: true },
+    orderBy: { createdAt: 'asc' as const },
+  },
+} as const
+
+export async function getEmailConversation(thread: EmailThreadRef, limit = 100) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const [messages, customer] = await Promise.all([
+        // The newest rows, put back in reading order: a thread longer than
+        // the limit shows its latest mail, not its first.
+        db.emailMessage
+          .findMany({
+            where: threadWhere(organizationId, thread),
+            orderBy: { createdAt: 'desc' },
+            take: Math.min(limit, 200),
+            select: MESSAGE_SELECT,
+          })
+          .then((rows) => rows.reverse()),
+        thread.customerId
+          ? db.customer.findFirst({
+              where: { id: thread.customerId, organizationId },
+              select: { id: true, name: true, email: true },
+            })
+          : null,
+      ])
+      return {
+        customer,
+        /** Where a reply goes: the customer's address, or the thread's. */
+        address: customer?.email ?? thread.contact.trim().toLowerCase(),
+        messages,
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}
+
+const MAX_SUBJECT = 200
+const MAX_BODY = 20_000
+
+export interface SendEmailMessageInput {
+  customerId: string
+  subject: string
+  body: string
+  /** The inbound message being answered, so the customer's client threads it. */
+  replyToId?: string | null
+}
+
+/**
+ * Writes to a customer from the inbox. Goes out through the same door as
+ * every customer mail, which on the cloud instance puts the reply address
+ * on it and files it in the thread; under the daily allowance on a plan
+ * without custom email, like any other platform send.
+ */
+export async function sendEmailMessage(input: SendEmailMessageInput) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      demoGuard()
+
+      const state = await emailChannelState(organizationId)
+      if (state !== 'active') {
+        throw new Error(
+          state === 'ownVendor'
+            ? 'Replies go to your own mail address while you send through your own vendor'
+            : 'Email conversations are not available here'
+        )
+      }
+      const sendingMode = await requireEmailSending(organizationId)
+
+      const subject = cleanSubject(input.subject)
+      const body = (input.body ?? '').replace(/\r\n?/g, '\n').trim()
+      if (!body) throw new Error('Write a message first')
+      if (body.length > MAX_BODY) throw new Error('The message is too long')
+      if (subject && subject.length > MAX_SUBJECT) throw new Error('The subject is too long')
+
+      const [customer, disabled, sender] = await Promise.all([
+        db.customer.findFirst({
+          where: { id: input.customerId, organizationId },
+          select: { id: true, name: true, email: true },
+        }),
+        db.appSetting.findUnique({
+          where: { organizationId_key: { organizationId, key: 'workshop.emailEnabled' } },
+          select: { value: true },
+        }),
+        db.user.findUnique({ where: { id: userId }, select: { name: true } }),
+      ])
+      if (!customer) throw new Error('Customer not found')
+      const to = normalizeEmailAddress(customer.email)
+      if (!to) throw new Error('This customer has no email address')
+      if (disabled?.value === 'false') {
+        throw new Error('Email sending is disabled. Enable it in Settings.')
+      }
+
+      // The mail being answered, from this workshop's own rows only.
+      const answered = input.replyToId
+        ? await db.emailMessage.findFirst({
+            where: { id: input.replyToId, organizationId, direction: 'inbound' },
+            select: { messageId: true, inReplyTo: true, subject: true },
+          })
+        : null
+      const references = [answered?.inReplyTo, answered?.messageId].filter((id): id is string =>
+        Boolean(id)
+      )
+
+      await withPlatformMailAllowance(organizationId, sendingMode, async () =>
+        sendTemplatedMail(organizationId, {
+          kind: 'message',
+          to,
+          subject: subject ?? (answered ? replySubject(answered.subject) : undefined),
+          context: {
+            message: body,
+            customerName: customer.name,
+            currentUser: sender?.name ?? null,
+          },
+          thread: {
+            customerId: customer.id,
+            kind: 'message',
+            inReplyTo: answered?.messageId ?? null,
+            references,
+          },
+        })
+      )
+
+      return { sent: true, customerId: customer.id }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}
+
+/** Customers with an address, for starting a conversation. */
+export async function searchEmailRecipients(search?: string, limit = 30) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const term = search?.trim()
+      return db.customer.findMany({
+        where: {
+          organizationId,
+          email: { not: null },
+          ...(term
+            ? {
+                OR: [
+                  { name: { contains: term, mode: 'insensitive' as const } },
+                  { email: { contains: term, mode: 'insensitive' as const } },
+                  { company: { contains: term, mode: 'insensitive' as const } },
+                ],
+              }
+            : {}),
+        },
+        select: { id: true, name: true, company: true, email: true },
+        orderBy: { name: 'asc' },
+        take: Math.min(limit, 100),
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}
+
+/**
+ * Removes our copy of a conversation and the files that came with it. The
+ * customer's mailbox keeps theirs. Files go through the file manager, which
+ * trashes rather than deletes and keeps anything another row still uses.
+ */
+export async function deleteEmailConversation(thread: EmailThreadRef) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const where = threadWhere(organizationId, thread)
+      const attachments = await db.emailAttachment.findMany({
+        where: { message: where },
+        select: { url: true },
+      })
+      const { count } = await db.emailMessage.deleteMany({ where })
+      await releaseFiles(
+        attachments.map((attachment) => attachment.url),
+        { organizationId, reason: 'email conversation deleted' }
+      )
+      return { deleted: count }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}

+ 98 - 16
src/features/messaging/Actions/inboxActions.ts

@@ -7,6 +7,7 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { getOrgSmsProvider } from '@/lib/sms'
 import { getOrgSmsProvider } from '@/lib/sms'
 import { getOrgTelegramBotToken } from '@/lib/telegram'
 import { getOrgTelegramBotToken } from '@/lib/telegram'
 import { isWhatsappConfigured } from '@/lib/whatsapp'
 import { isWhatsappConfigured } from '@/lib/whatsapp'
+import { type EmailChannelState, emailChannelState } from '../Lib/emailChannel.server'
 import { mergeChannelPages } from '../Lib/mergeChannelPages'
 import { mergeChannelPages } from '../Lib/mergeChannelPages'
 
 
 /**
 /**
@@ -22,7 +23,8 @@ import { mergeChannelPages } from '../Lib/mergeChannelPages'
  * otherwise would put replies on the wrong one.
  * otherwise would put replies on the wrong one.
  */
  */
 
 
-export type MessagingChannel = 'sms' | 'whatsapp' | 'telegram'
+export type MessagingChannel = 'sms' | 'whatsapp' | 'telegram' | 'email'
+export type { EmailChannelState }
 
 
 export interface InboxThread {
 export interface InboxThread {
   /** Stable across renders: channel plus whoever the thread belongs to. */
   /** Stable across renders: channel plus whoever the thread belongs to. */
@@ -30,7 +32,7 @@ export interface InboxThread {
   channel: MessagingChannel
   channel: MessagingChannel
   customerId: string | null
   customerId: string | null
   name: string
   name: string
-  /** Phone number or chat id, shown under the name. */
+  /** Phone number, chat id or email address, shown under the name. */
   contact: string
   contact: string
   lastMessage: string
   lastMessage: string
   lastDirection: string
   lastDirection: string
@@ -49,6 +51,12 @@ export interface InboxPage {
   nextCursor: string | null
   nextCursor: string | null
   /** Channels the workshop can actually send on, for the composer. */
   /** Channels the workshop can actually send on, for the composer. */
   channels: MessagingChannel[]
   channels: MessagingChannel[]
+  /**
+   * The email channel's standing, beyond whether it is in `channels`: a
+   * workshop with its own mail vendor sees it listed but disabled, with the
+   * reason, rather than finding it missing.
+   */
+  emailState: EmailChannelState
 }
 }
 
 
 const DEFAULT_PAGE_SIZE = 30
 const DEFAULT_PAGE_SIZE = 30
@@ -122,16 +130,18 @@ export async function getInboxThreads(query: InboxQuery = {}) {
       const pattern = term ? `%${term}%` : null
       const pattern = term ? `%${term}%` : null
 
 
       const features = await getFeatures(organizationId)
       const features = await getFeatures(organizationId)
-      const [smsProvider, whatsappReady, telegramToken] = await Promise.all([
+      const [smsProvider, whatsappReady, telegramToken, emailState] = await Promise.all([
         features.sms ? getOrgSmsProvider(organizationId).catch(() => null) : null,
         features.sms ? getOrgSmsProvider(organizationId).catch(() => null) : null,
         features.whatsapp ? isWhatsappConfigured(organizationId).catch(() => false) : false,
         features.whatsapp ? isWhatsappConfigured(organizationId).catch(() => false) : false,
         features.telegram ? getOrgTelegramBotToken(organizationId).catch(() => null) : null,
         features.telegram ? getOrgTelegramBotToken(organizationId).catch(() => null) : null,
+        emailChannelState(organizationId).catch((): EmailChannelState => 'off'),
       ])
       ])
 
 
       const channels: MessagingChannel[] = []
       const channels: MessagingChannel[] = []
       if (smsProvider) channels.push('sms')
       if (smsProvider) channels.push('sms')
       if (whatsappReady) channels.push('whatsapp')
       if (whatsappReady) channels.push('whatsapp')
       if (telegramToken) channels.push('telegram')
       if (telegramToken) channels.push('telegram')
+      if (emailState === 'active') channels.push('email')
 
 
       // DISTINCT ON gives the newest message per thread in one pass, which the
       // DISTINCT ON gives the newest message per thread in one pass, which the
       // (organizationId, customerId, createdAt DESC) indexes already order for.
       // (organizationId, customerId, createdAt DESC) indexes already order for.
@@ -141,9 +151,17 @@ export async function getInboxThreads(query: InboxQuery = {}) {
       // Unread is counted per thread in its own small query rather than
       // Unread is counted per thread in its own small query rather than
       // folded into the DISTINCT ON, which only ever sees one row per thread.
       // folded into the DISTINCT ON, which only ever sees one row per thread.
       // Unread rows are few, so the whole workshop is counted at once.
       // Unread rows are few, so the whole workshop is counted at once.
-      const [smsRows, telegramRows, whatsappRows, smsUnread, telegramUnread, whatsappUnread] =
-        await Promise.all([
-          db.$queryRaw<ThreadRow[]>`
+      const [
+        smsRows,
+        telegramRows,
+        whatsappRows,
+        emailRows,
+        smsUnread,
+        telegramUnread,
+        whatsappUnread,
+        emailUnread,
+      ] = await Promise.all([
+        db.$queryRaw<ThreadRow[]>`
           SELECT * FROM (
           SELECT * FROM (
             SELECT DISTINCT ON (m."customerId")
             SELECT DISTINCT ON (m."customerId")
               m."customerId" AS identity,
               m."customerId" AS identity,
@@ -166,7 +184,7 @@ export async function getInboxThreads(query: InboxQuery = {}) {
           ORDER BY t."createdAt" DESC
           ORDER BY t."createdAt" DESC
           LIMIT ${limit}
           LIMIT ${limit}
         `,
         `,
-          db.$queryRaw<ThreadRow[]>`
+        db.$queryRaw<ThreadRow[]>`
           SELECT * FROM (
           SELECT * FROM (
             SELECT DISTINCT ON (m."customerId")
             SELECT DISTINCT ON (m."customerId")
               m."customerId" AS identity,
               m."customerId" AS identity,
@@ -189,10 +207,10 @@ export async function getInboxThreads(query: InboxQuery = {}) {
           ORDER BY t."createdAt" DESC
           ORDER BY t."createdAt" DESC
           LIMIT ${limit}
           LIMIT ${limit}
         `,
         `,
-          // WhatsApp threads can belong to a number we never matched to a
-          // customer, so they group by customer when there is one and by the
-          // number itself when there is not.
-          db.$queryRaw<ThreadRow[]>`
+        // WhatsApp threads can belong to a number we never matched to a
+        // customer, so they group by customer when there is one and by the
+        // number itself when there is not.
+        db.$queryRaw<ThreadRow[]>`
           SELECT * FROM (
           SELECT * FROM (
             SELECT DISTINCT ON (COALESCE(m."customerId", CASE WHEN m."direction" = 'inbound' THEN m."fromNumber" ELSE m."toNumber" END))
             SELECT DISTINCT ON (COALESCE(m."customerId", CASE WHEN m."direction" = 'inbound' THEN m."fromNumber" ELSE m."toNumber" END))
               COALESCE(m."customerId", CASE WHEN m."direction" = 'inbound' THEN m."fromNumber" ELSE m."toNumber" END) AS identity,
               COALESCE(m."customerId", CASE WHEN m."direction" = 'inbound' THEN m."fromNumber" ELSE m."toNumber" END) AS identity,
@@ -216,39 +234,81 @@ export async function getInboxThreads(query: InboxQuery = {}) {
           ORDER BY t."createdAt" DESC
           ORDER BY t."createdAt" DESC
           LIMIT ${limit}
           LIMIT ${limit}
         `,
         `,
-          db.$queryRaw<UnreadRow[]>`
+        // Email threads group by customer, or by the address when the
+        // sender matched nobody, like WhatsApp. Only threads with a message
+        // in them are listed: a document mail (an invoice, a reminder) is
+        // kept for context and surfaces once the customer answers, so an
+        // invoice run does not fill the inbox with one-sided threads.
+        db.$queryRaw<ThreadRow[]>`
+          SELECT * FROM (
+            SELECT DISTINCT ON (COALESCE(m."customerId", m."address"))
+              COALESCE(m."customerId", m."address") AS identity,
+              m."customerId" AS "customerId",
+              COALESCE(c."name", m."fromName") AS name,
+              m."address" AS contact,
+              CASE WHEN m."kind" = 'document' THEN m."subject" ELSE COALESCE(NULLIF(m."body", ''), m."subject") END AS body,
+              m."direction" AS direction,
+              m."createdAt" AS "createdAt"
+            FROM "public"."email_messages" m
+            LEFT JOIN "public"."customers" c ON c."id" = m."customerId"
+            WHERE m."organizationId" = ${organizationId}
+              AND EXISTS (
+                SELECT 1 FROM "public"."email_messages" x
+                WHERE x."organizationId" = m."organizationId"
+                  AND COALESCE(x."customerId", x."address") = COALESCE(m."customerId", m."address")
+                  AND x."kind" = 'message'
+              )
+            ORDER BY COALESCE(m."customerId", m."address"), m."createdAt" DESC
+          ) t
+          WHERE (${cursor}::timestamptz IS NULL OR t."createdAt" < ${cursor}::timestamptz)
+            AND (
+              ${pattern}::text IS NULL
+              OR t.name ILIKE ${pattern} OR t.contact ILIKE ${pattern} OR t.body ILIKE ${pattern}
+            )
+          ORDER BY t."createdAt" DESC
+          LIMIT ${limit}
+        `,
+        db.$queryRaw<UnreadRow[]>`
           SELECT "customerId" AS identity, COUNT(*)::int AS unread
           SELECT "customerId" AS identity, COUNT(*)::int AS unread
           FROM "public"."sms_messages"
           FROM "public"."sms_messages"
           WHERE "organizationId" = ${organizationId}
           WHERE "organizationId" = ${organizationId}
             AND "direction" = 'inbound' AND "readAt" IS NULL AND "customerId" IS NOT NULL
             AND "direction" = 'inbound' AND "readAt" IS NULL AND "customerId" IS NOT NULL
           GROUP BY "customerId"
           GROUP BY "customerId"
         `,
         `,
-          db.$queryRaw<UnreadRow[]>`
+        db.$queryRaw<UnreadRow[]>`
           SELECT "customerId" AS identity, COUNT(*)::int AS unread
           SELECT "customerId" AS identity, COUNT(*)::int AS unread
           FROM "public"."telegram_messages"
           FROM "public"."telegram_messages"
           WHERE "organizationId" = ${organizationId}
           WHERE "organizationId" = ${organizationId}
             AND "direction" = 'inbound' AND "readAt" IS NULL AND "customerId" IS NOT NULL
             AND "direction" = 'inbound' AND "readAt" IS NULL AND "customerId" IS NOT NULL
           GROUP BY "customerId"
           GROUP BY "customerId"
         `,
         `,
-          db.$queryRaw<UnreadRow[]>`
+        db.$queryRaw<UnreadRow[]>`
           SELECT COALESCE("customerId", "fromNumber") AS identity, COUNT(*)::int AS unread
           SELECT COALESCE("customerId", "fromNumber") AS identity, COUNT(*)::int AS unread
           FROM "public"."whatsapp_messages"
           FROM "public"."whatsapp_messages"
           WHERE "organizationId" = ${organizationId}
           WHERE "organizationId" = ${organizationId}
             AND "direction" = 'inbound' AND "readAt" IS NULL
             AND "direction" = 'inbound' AND "readAt" IS NULL
           GROUP BY COALESCE("customerId", "fromNumber")
           GROUP BY COALESCE("customerId", "fromNumber")
         `,
         `,
-        ])
+        db.$queryRaw<UnreadRow[]>`
+          SELECT COALESCE("customerId", "address") AS identity, COUNT(*)::int AS unread
+          FROM "public"."email_messages"
+          WHERE "organizationId" = ${organizationId}
+            AND "direction" = 'inbound' AND "readAt" IS NULL
+          GROUP BY COALESCE("customerId", "address")
+        `,
+      ])
 
 
       const { threads, nextCursor } = mergeChannelPages(
       const { threads, nextCursor } = mergeChannelPages(
         [
         [
           smsRows.map((row) => toThread('sms', row, byIdentity(smsUnread))),
           smsRows.map((row) => toThread('sms', row, byIdentity(smsUnread))),
           telegramRows.map((row) => toThread('telegram', row, byIdentity(telegramUnread))),
           telegramRows.map((row) => toThread('telegram', row, byIdentity(telegramUnread))),
           whatsappRows.map((row) => toThread('whatsapp', row, byIdentity(whatsappUnread))),
           whatsappRows.map((row) => toThread('whatsapp', row, byIdentity(whatsappUnread))),
+          emailRows.map((row) => toThread('email', row, byIdentity(emailUnread))),
         ],
         ],
         limit
         limit
       )
       )
 
 
-      return { threads, nextCursor, channels }
+      return { threads, nextCursor, channels, emailState }
     },
     },
     {
     {
       requiredPermissions: [
       requiredPermissions: [
@@ -287,6 +347,16 @@ export async function markThreadRead(thread: {
         return { marked: result.count }
         return { marked: result.count }
       }
       }
 
 
+      if (thread.channel === 'email') {
+        const result = await db.emailMessage.updateMany({
+          where: thread.customerId
+            ? { ...unread, customerId: thread.customerId }
+            : { ...unread, customerId: null, address: thread.contact.toLowerCase() },
+          data: { readAt: now },
+        })
+        return { marked: result.count }
+      }
+
       if (!thread.customerId) return { marked: 0 }
       if (!thread.customerId) return { marked: 0 }
 
 
       const result =
       const result =
@@ -339,6 +409,18 @@ export async function markThreadUnread(thread: {
         return { marked: 1 }
         return { marked: 1 }
       }
       }
 
 
+      if (thread.channel === 'email') {
+        const latest = await db.emailMessage.findFirst({
+          where: thread.customerId
+            ? { ...inbound, customerId: thread.customerId }
+            : { ...inbound, customerId: null, address: thread.contact.toLowerCase() },
+          ...newest,
+        })
+        if (!latest) return { marked: 0 }
+        await db.emailMessage.update({ where: { id: latest.id }, data: { readAt: null } })
+        return { marked: 1 }
+      }
+
       if (!thread.customerId) return { marked: 0 }
       if (!thread.customerId) return { marked: 0 }
 
 
       if (thread.channel === 'sms') {
       if (thread.channel === 'sms') {

+ 6 - 1
src/features/messaging/Components/ChannelBadge.tsx

@@ -1,7 +1,7 @@
 'use client'
 'use client'
 
 
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
-import { MessageCircle, MessageSquare, Send } from 'lucide-react'
+import { Mail, MessageCircle, MessageSquare, Send } from 'lucide-react'
 import type { MessagingChannel } from '../Actions/inboxActions'
 import type { MessagingChannel } from '../Actions/inboxActions'
 
 
 /**
 /**
@@ -27,6 +27,11 @@ const STYLES: Record<MessagingChannel, { label: string; className: string; Icon:
     className: 'bg-blue-500/15 text-blue-700 dark:text-blue-300',
     className: 'bg-blue-500/15 text-blue-700 dark:text-blue-300',
     Icon: Send,
     Icon: Send,
   },
   },
+  email: {
+    label: 'Email',
+    className: 'bg-amber-500/15 text-amber-700 dark:text-amber-300',
+    Icon: Mail,
+  },
 }
 }
 
 
 export function ChannelBadge({
 export function ChannelBadge({

+ 486 - 0
src/features/messaging/Components/EmailConversation.tsx

@@ -0,0 +1,486 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import {
+  AlertCircle,
+  FileText,
+  Image as ImageIcon,
+  Loader2,
+  Mail,
+  Send,
+  ShieldAlert,
+} from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { cn } from '@/lib/utils'
+import type { EmailChannelState } from '../Actions/inboxActions'
+import { getEmailConversation, sendEmailMessage } from '../Actions/emailThreadActions'
+import { replySubject, splitQuotedReply } from '../Lib/inboundMail'
+import { MessageText } from './MessageText'
+
+export interface EmailThread {
+  key: string
+  customerId: string | null
+  name: string
+  address: string
+}
+
+/** Taken from what the action returns, so the view cannot drift from it. */
+type EmailConversationData = NonNullable<Awaited<ReturnType<typeof getEmailConversation>>['data']>
+type EmailMessageRow = EmailConversationData['messages'][number]
+type EmailAttachmentRow = EmailMessageRow['attachments'][number]
+
+/**
+ * One email conversation, read like a chat.
+ *
+ * Mail carries what chat does not: a subject, a quoted copy of everything
+ * before it, files. The subject is shown only where it changes, the quote is
+ * folded away, and the files sit under the text, so a thread of replies still
+ * reads as a conversation rather than a stack of envelopes.
+ *
+ * Whether there is a box to answer in depends on where replies go. A workshop
+ * that sends through its own vendor gets its replies in its own mailbox, so
+ * the thread here is history and the answer is a link to the mail app.
+ */
+export function EmailConversation({
+  thread,
+  emailState,
+  onSent,
+  refreshKey,
+  className,
+}: {
+  thread: EmailThread
+  emailState: EmailChannelState
+  /** Lets the surrounding list reload once a message is on its way. */
+  onSent?: () => void
+  /**
+   * Changes when the list hears of a newer message on this thread, so the
+   * history is re-read without being reopened.
+   */
+  refreshKey?: string
+  /** How the surrounding pane wants this sized. */
+  className?: string
+}) {
+  const t = useTranslations('messaging.email')
+  const router = useRouter()
+  const [messages, setMessages] = useState<EmailMessageRow[]>([])
+  const [address, setAddress] = useState(thread.address)
+  const [loading, setLoading] = useState(true)
+  const [body, setBody] = useState('')
+  /** Null until the user edits it, so the subject follows the thread. */
+  const [subjectDraft, setSubjectDraft] = useState<string | null>(null)
+  const [isSending, startSending] = useTransition()
+  const [mounted, setMounted] = useState(false)
+  const endRef = useRef<HTMLDivElement>(null)
+  const scrollAreaRef = useRef<HTMLDivElement>(null)
+  /** Whether the reader is at the newest message, so growth may follow. */
+  const atBottom = useRef(true)
+
+  useEffect(() => setMounted(true), [])
+
+  const load = useCallback(async () => {
+    const result = await getEmailConversation({
+      customerId: thread.customerId,
+      contact: thread.address,
+    })
+    if (result.success && result.data) {
+      setMessages(result.data.messages)
+      setAddress(result.data.address)
+    }
+    setLoading(false)
+  }, [thread.customerId, thread.address])
+
+  useEffect(() => {
+    setLoading(true)
+    load()
+  }, [load])
+
+  // A reply that arrived while this was open: re-read quietly, keeping what
+  // is on screen until the new rows are in.
+  const lastRefreshKey = useRef(refreshKey)
+  useEffect(() => {
+    if (refreshKey === lastRefreshKey.current) return
+    lastRefreshKey.current = refreshKey
+    void load()
+  }, [refreshKey, load])
+
+  // Land on the newest message once the history is on screen, not before:
+  // scrolling an empty list leaves the reader at the top.
+  const initialScrollDone = useRef(false)
+  useEffect(() => {
+    const el = scrollAreaRef.current
+    if (!el || loading || messages.length === 0) return
+
+    if (initialScrollDone.current) {
+      el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
+      return
+    }
+    initialScrollDone.current = true
+    // Two frames: one for the messages to render, one for layout to settle.
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        el.scrollTop = el.scrollHeight
+      })
+    })
+  }, [messages.length, loading])
+
+  // Thumbnails have no height until they load. Follow the growth, but only
+  // while the reader is already at the bottom.
+  useEffect(() => {
+    const el = scrollAreaRef.current
+    if (!el || typeof ResizeObserver === 'undefined') return
+
+    const observer = new ResizeObserver(() => {
+      if (atBottom.current) el.scrollTop = el.scrollHeight
+    })
+    for (const child of Array.from(el.children)) observer.observe(child)
+    return () => observer.disconnect()
+  }, [messages.length, loading])
+
+  const onScroll = () => {
+    const el = scrollAreaRef.current
+    if (!el) return
+    atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80
+  }
+
+  /** The subject a reply continues, and the mail it answers. */
+  const { lastSubject, answeringId } = useMemo(() => {
+    let subject: string | null = null
+    let inboundId: string | null = null
+    for (let index = messages.length - 1; index >= 0; index--) {
+      const message = messages[index]
+      if (!subject && message.subject?.trim()) subject = message.subject
+      if (!inboundId && message.direction === 'inbound') inboundId = message.id
+      if (subject && inboundId) break
+    }
+    return { lastSubject: subject, answeringId: inboundId }
+  }, [messages])
+
+  const defaultSubject = lastSubject ? replySubject(lastSubject) : ''
+  const subject = subjectDraft ?? defaultSubject
+
+  const handleSend = () => {
+    const customerId = thread.customerId
+    // Enter in the box is not disabled while a send is running, so the
+    // guard is here: the body is only cleared once the first send is through.
+    if (isSending || !customerId || !body.trim()) return
+
+    startSending(async () => {
+      const result = await sendEmailMessage({
+        customerId,
+        subject: subject.trim(),
+        body: body.trim(),
+        replyToId: answeringId,
+      })
+
+      if (!result.success) {
+        toast.error(result.error ?? t('sendFailed'))
+        return
+      }
+
+      setBody('')
+      setSubjectDraft(null)
+      await load()
+      // The thread list is server-rendered, so a first mail to someone new
+      // only appears there once the page reloads its data.
+      router.refresh()
+      onSent?.()
+      endRef.current?.scrollIntoView({ behavior: 'smooth' })
+    })
+  }
+
+  const mailto = `mailto:${address}${
+    lastSubject ? `?subject=${encodeURIComponent(replySubject(lastSubject))}` : ''
+  }`
+
+  return (
+    <div className={cn('flex h-full min-h-0 flex-col', className)}>
+      <div
+        ref={scrollAreaRef}
+        onScroll={onScroll}
+        className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4"
+      >
+        {loading ? (
+          <div className="flex justify-center py-8">
+            <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+          </div>
+        ) : messages.length === 0 ? (
+          <p className="py-8 text-center text-sm text-muted-foreground">{t('empty')}</p>
+        ) : (
+          messages.map((message, index) => (
+            <EmailMessageItem
+              key={message.id}
+              message={message}
+              showSubject={
+                index === 0 ||
+                subjectKey(messages[index - 1].subject) !== subjectKey(message.subject)
+              }
+              mounted={mounted}
+            />
+          ))
+        )}
+        <div ref={endRef} />
+      </div>
+
+      {emailState === 'active' && (
+        <div className="shrink-0 space-y-2 border-t bg-background p-4">
+          {!thread.customerId ? (
+            <p className="text-sm text-muted-foreground">{t('unknownSender')}</p>
+          ) : (
+            <>
+              <Input
+                value={subject}
+                onChange={(event) => setSubjectDraft(event.target.value)}
+                placeholder={t('subjectPlaceholder')}
+                maxLength={200}
+                className="h-9"
+              />
+              <div className="flex items-end gap-2">
+                <Textarea
+                  value={body}
+                  onChange={(event) => setBody(event.target.value)}
+                  placeholder={t('placeholder')}
+                  rows={2}
+                  className="min-h-[44px] flex-1 resize-none"
+                  onKeyDown={(event) => {
+                    if (event.key === 'Enter' && !event.shiftKey) {
+                      event.preventDefault()
+                      handleSend()
+                    }
+                  }}
+                />
+                <Button
+                  type="button"
+                  onClick={handleSend}
+                  disabled={isSending || !body.trim()}
+                  aria-label={t('send')}
+                >
+                  {isSending ? (
+                    <Loader2 className="h-4 w-4 animate-spin" />
+                  ) : (
+                    <Send className="h-4 w-4" />
+                  )}
+                </Button>
+              </div>
+            </>
+          )}
+        </div>
+      )}
+
+      {emailState === 'ownVendor' && (
+        <div className="shrink-0 border-t bg-background p-4">
+          <p className="flex items-start gap-2 rounded-lg border bg-muted/50 p-3 text-xs text-muted-foreground">
+            <Mail className="mt-0.5 h-3.5 w-3.5 shrink-0" />
+            <span>
+              {t('ownVendorThread')}
+              <a href={mailto} className="ml-1 font-medium text-primary hover:underline">
+                {t('replyFromMailApp')}
+              </a>
+            </span>
+          </p>
+        </div>
+      )}
+    </div>
+  )
+}
+
+/**
+ * A subject with its reply prefixes and case set aside, so "Re: Brakes"
+ * under "Brakes" is the same conversation and does not repeat its heading.
+ */
+function subjectKey(subject: string | null | undefined): string {
+  let base = (subject ?? '').trim()
+  const prefix = /^(re|sv|aw|antw|res|odp|ynt|atb|fw|fwd|vs|wg|tr|ответ)\s*:\s*/i
+  while (prefix.test(base)) base = base.replace(prefix, '')
+  return base.toLowerCase()
+}
+
+function formatSize(bytes: number): string {
+  if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+/** Today's mail shows the time; older mail needs the day too. */
+function formatWhen(value: string | Date): string {
+  const date = new Date(value)
+  const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+  if (date.toDateString() === new Date().toDateString()) return time
+  return `${date.toLocaleDateString([], { day: 'numeric', month: 'short' })} ${time}`
+}
+
+/**
+ * One mail in the thread. A document the app sent (an invoice, a quote, a
+ * reminder) is an event in the conversation rather than something someone
+ * wrote, so it is a quiet line in the middle instead of a bubble.
+ */
+function EmailMessageItem({
+  message,
+  showSubject,
+  mounted,
+}: {
+  message: EmailMessageRow
+  showSubject: boolean
+  mounted: boolean
+}) {
+  const t = useTranslations('messaging.email')
+  const [showQuoted, setShowQuoted] = useState(false)
+  const isOutbound = message.direction === 'outbound'
+  const failed = message.status === 'failed'
+  const flagged = message.spamVerdict === 'FAIL' || message.spamVerdict === 'GRAY'
+  const time = mounted ? formatWhen(message.createdAt) : ''
+
+  if (message.kind === 'document') {
+    const note = message.body.trim()
+    return (
+      <div className="flex justify-center">
+        <div className="flex max-w-[85%] flex-col items-center gap-1 rounded-lg border bg-muted/40 px-3 py-2 text-center text-xs text-muted-foreground">
+          <span className="flex items-center gap-1.5 font-medium text-foreground/80">
+            <FileText className="h-3.5 w-3.5 shrink-0" />
+            <span className="break-words">
+              {t('sentDocument', { subject: message.subject ?? t('attachment') })}
+            </span>
+          </span>
+          {note && <MessageText body={note} className="text-xs" />}
+          {message.attachments.length > 0 && (
+            <AttachmentChips attachments={message.attachments} isOutbound={false} />
+          )}
+          <span className="text-[10px]">{time}</span>
+          {failed && message.errorMessage && (
+            <p className="flex items-start gap-1.5 text-[11px] text-destructive">
+              <AlertCircle className="mt-0.5 h-3 w-3 shrink-0" />
+              {message.errorMessage}
+            </p>
+          )}
+        </div>
+      </div>
+    )
+  }
+
+  const { reply, quoted } = isOutbound
+    ? { reply: message.body, quoted: '' }
+    : splitQuotedReply(message.body)
+  const images = message.attachments.filter((file) => file.contentType.startsWith('image/'))
+
+  return (
+    <div className={cn('flex', isOutbound ? 'justify-end' : 'justify-start')}>
+      <div
+        className={cn(
+          'min-w-0 max-w-[75%] space-y-2 rounded-2xl px-4 py-2',
+          isOutbound ? 'bg-primary text-primary-foreground' : 'bg-muted',
+          failed && 'bg-destructive/10 text-foreground'
+        )}
+      >
+        {showSubject && message.subject && (
+          <p className="break-words text-xs font-semibold">{message.subject}</p>
+        )}
+
+        {reply.trim() && <MessageText body={reply.trim()} />}
+
+        {quoted.trim() && (
+          <div className="space-y-1">
+            <Button
+              type="button"
+              variant="ghost"
+              size="sm"
+              className="-ml-2 h-6 px-2 text-xs text-muted-foreground"
+              onClick={() => setShowQuoted((value) => !value)}
+            >
+              {showQuoted ? t('hideQuoted') : t('showQuoted')}
+            </Button>
+            {showQuoted && (
+              <p className="whitespace-pre-wrap break-words border-l-2 pl-3 text-xs text-muted-foreground">
+                {quoted.trim()}
+              </p>
+            )}
+          </div>
+        )}
+
+        {images.length > 0 && (
+          <div className="flex flex-wrap gap-2">
+            {images.map((file) => (
+              <a key={file.id} href={file.url} target="_blank" rel="noopener noreferrer">
+                {/* Served by our own file route, which next/image would not add anything to. */}
+                <img
+                  src={file.url}
+                  alt={file.fileName}
+                  loading="lazy"
+                  className="max-h-40 max-w-full rounded-lg object-cover"
+                />
+              </a>
+            ))}
+          </div>
+        )}
+
+        {message.attachments.length > 0 && (
+          <AttachmentChips attachments={message.attachments} isOutbound={isOutbound && !failed} />
+        )}
+
+        <div
+          className={cn(
+            'flex items-center gap-1.5 text-[10px]',
+            isOutbound && !failed
+              ? 'justify-end text-primary-foreground/70'
+              : 'text-muted-foreground'
+          )}
+        >
+          <span>{time}</span>
+          {isOutbound && <span className="capitalize">· {message.status}</span>}
+        </div>
+
+        {flagged && (
+          <p className="flex items-start gap-1.5 text-[11px] text-amber-700 dark:text-amber-300">
+            <ShieldAlert className="mt-0.5 h-3 w-3 shrink-0" />
+            {t('spamFlag')}
+          </p>
+        )}
+
+        {failed && message.errorMessage && (
+          <p className="flex items-start gap-1.5 text-[11px] text-destructive">
+            <AlertCircle className="mt-0.5 h-3 w-3 shrink-0" />
+            {message.errorMessage}
+          </p>
+        )}
+      </div>
+    </div>
+  )
+}
+
+function AttachmentChips({
+  attachments,
+  isOutbound,
+}: {
+  attachments: EmailAttachmentRow[]
+  isOutbound: boolean
+}) {
+  return (
+    <div className="flex flex-wrap gap-1.5">
+      {attachments.map((file) => {
+        const Icon = file.contentType.startsWith('image/') ? ImageIcon : FileText
+        return (
+          <a
+            key={file.id}
+            href={file.url}
+            target="_blank"
+            rel="noopener noreferrer"
+            className={cn(
+              'flex min-w-0 max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors',
+              isOutbound
+                ? 'border-primary-foreground/20 bg-primary-foreground/10 hover:bg-primary-foreground/20'
+                : 'bg-background hover:bg-background/70'
+            )}
+          >
+            <Icon className="h-3.5 w-3.5 shrink-0" />
+            <span className="min-w-0 truncate">{file.fileName}</span>
+            <span className={isOutbound ? 'text-primary-foreground/70' : 'text-muted-foreground'}>
+              {formatSize(file.size)}
+            </span>
+          </a>
+        )
+      })}
+    </div>
+  )
+}

+ 5 - 1
src/features/messaging/Components/MessagesPageClient.tsx

@@ -18,7 +18,7 @@ import {
   type ScheduledMessageListItem,
   type ScheduledMessageListItem,
 } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
 } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
 import type { MessageChannel } from '@/features/scheduled-messages/Schema/scheduledMessageSchema'
 import type { MessageChannel } from '@/features/scheduled-messages/Schema/scheduledMessageSchema'
-import type { InboxThread, MessagingChannel } from '../Actions/inboxActions'
+import type { EmailChannelState, InboxThread, MessagingChannel } from '../Actions/inboxActions'
 import { type InboxFocus, UnifiedInbox } from './UnifiedInbox'
 import { type InboxFocus, UnifiedInbox } from './UnifiedInbox'
 
 
 /**
 /**
@@ -35,6 +35,7 @@ export function MessagesPageClient({
   threads,
   threads,
   initialCursor = null,
   initialCursor = null,
   channels,
   channels,
+  emailState,
   initialScheduled = [],
   initialScheduled = [],
   campaigns = [],
   campaigns = [],
   availableChannels = [],
   availableChannels = [],
@@ -45,6 +46,8 @@ export function MessagesPageClient({
   initialCursor?: string | null
   initialCursor?: string | null
   /** Channels with a working provider, for the composer. */
   /** Channels with a working provider, for the composer. */
   channels: MessagingChannel[]
   channels: MessagingChannel[]
+  /** Whether email is a channel here, one the workshop answers elsewhere, or neither. */
+  emailState: EmailChannelState
   initialScheduled?: ScheduledMessageListItem[]
   initialScheduled?: ScheduledMessageListItem[]
   campaigns?: CampaignListItem[]
   campaigns?: CampaignListItem[]
   availableChannels?: MessageChannel[]
   availableChannels?: MessageChannel[]
@@ -155,6 +158,7 @@ export function MessagesPageClient({
             threads={threads}
             threads={threads}
             initialCursor={initialCursor}
             initialCursor={initialCursor}
             channels={channels}
             channels={channels}
+            emailState={emailState}
             onChanged={() => startRefresh(() => router.refresh())}
             onChanged={() => startRefresh(() => router.refresh())}
           />
           />
         )}
         )}

+ 112 - 0
src/features/messaging/Components/NewEmailDialog.tsx

@@ -0,0 +1,112 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { Loader2, Mail, Search, User } from 'lucide-react'
+import { Input } from '@/components/ui/input'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { useDebouncedSearch } from '@/hooks/use-debounced-search'
+import { searchEmailRecipients } from '../Actions/emailThreadActions'
+
+export interface EmailRecipient {
+  id: string
+  name: string
+  company: string | null
+  email: string | null
+}
+
+/**
+ * Picks who to write to by email.
+ *
+ * Like the WhatsApp picker it carries no message box: the subject a mail
+ * needs, and the history a reply should follow, both live in the thread, so
+ * this hands over to it and stops there.
+ */
+export function NewEmailDialog({
+  open,
+  onOpenChange,
+  onSelect,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  onSelect: (recipient: EmailRecipient) => void
+}) {
+  const t = useTranslations('messaging.email.compose')
+  const [results, setResults] = useState<EmailRecipient[]>([])
+  const [searching, setSearching] = useState(false)
+
+  const runSearch = async (term?: string) => {
+    setSearching(true)
+    const result = await searchEmailRecipients(term)
+    setResults(result.success && result.data ? result.data : [])
+    setSearching(false)
+  }
+
+  const { value: search, setValue: setSearch } = useDebouncedSearch('', runSearch)
+
+  useEffect(() => {
+    if (open) runSearch()
+    // Reloading on every keystroke is the debounced search's job.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open])
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t('title')}</DialogTitle>
+        </DialogHeader>
+
+        <div className="relative">
+          <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
+          <Input
+            value={search}
+            onChange={(event) => setSearch(event.target.value)}
+            placeholder={t('searchPlaceholder')}
+            className="h-9 pl-8"
+          />
+        </div>
+
+        <div className="max-h-72 min-h-24 overflow-y-auto rounded-lg border">
+          {searching ? (
+            <div className="flex items-center justify-center py-8">
+              <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+            </div>
+          ) : results.length === 0 ? (
+            <p className="px-4 py-8 text-center text-sm text-muted-foreground">
+              {search ? t('noMatches') : t('noRecipients')}
+            </p>
+          ) : (
+            results.map((recipient) => (
+              <button
+                key={recipient.id}
+                type="button"
+                className="flex w-full items-center gap-3 border-b px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
+                onClick={() => {
+                  onSelect(recipient)
+                  onOpenChange(false)
+                }}
+              >
+                <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
+                  <User className="h-4 w-4 text-muted-foreground" />
+                </span>
+                <span className="min-w-0">
+                  <span className="block truncate text-sm font-medium">
+                    {recipient.name}
+                    {recipient.company && (
+                      <span className="text-muted-foreground"> · {recipient.company}</span>
+                    )}
+                  </span>
+                  <span className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
+                    <Mail className="h-3 w-3 shrink-0" />
+                    <span className="truncate">{recipient.email}</span>
+                  </span>
+                </span>
+              </button>
+            ))
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 125 - 19
src/features/messaging/Components/UnifiedInbox.tsx

@@ -1,8 +1,10 @@
 'use client'
 'use client'
 
 
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
+import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
+import { useNotificationStore } from '@/features/notifications/store/notificationStore'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import {
 import {
   ArrowLeft,
   ArrowLeft,
@@ -60,10 +62,14 @@ import {
   getInboxThreads,
   getInboxThreads,
   markThreadRead,
   markThreadRead,
   markThreadUnread,
   markThreadUnread,
+  type EmailChannelState,
   type InboxThread,
   type InboxThread,
   type MessagingChannel,
   type MessagingChannel,
 } from '../Actions/inboxActions'
 } from '../Actions/inboxActions'
+import { deleteEmailConversation } from '../Actions/emailThreadActions'
 import { ChannelBadge, channelLabel } from './ChannelBadge'
 import { ChannelBadge, channelLabel } from './ChannelBadge'
+import { EmailConversation } from './EmailConversation'
+import { NewEmailDialog } from './NewEmailDialog'
 import { avatarTint, initials } from '../Lib/threadDisplay'
 import { avatarTint, initials } from '../Lib/threadDisplay'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 
 
@@ -90,7 +96,7 @@ export interface InboxFocus {
 }
 }
 
 
 /** Channels a workshop can start a conversation on. */
 /** Channels a workshop can start a conversation on. */
-const INITIABLE: MessagingChannel[] = ['sms', 'whatsapp']
+const INITIABLE: MessagingChannel[] = ['sms', 'whatsapp', 'email']
 
 
 /**
 /**
  * Taken from what the actions actually return rather than declared here.
  * Taken from what the actions actually return rather than declared here.
@@ -107,6 +113,7 @@ export function UnifiedInbox({
   threads: initialThreads,
   threads: initialThreads,
   initialCursor = null,
   initialCursor = null,
   channels,
   channels,
+  emailState,
   onChanged,
   onChanged,
   focus = null,
   focus = null,
 }: {
 }: {
@@ -114,12 +121,18 @@ export function UnifiedInbox({
   /** Where the next page starts, or null when the first page is all of it. */
   /** Where the next page starts, or null when the first page is all of it. */
   initialCursor?: string | null
   initialCursor?: string | null
   channels: MessagingChannel[]
   channels: MessagingChannel[]
+  /**
+   * Where email replies land. With the workshop's own vendor they go to its
+   * own mailbox, so email shows as a channel that is answered elsewhere.
+   */
+  emailState: EmailChannelState
   /** Asks the page to reload its server data after something is sent. */
   /** Asks the page to reload its server data after something is sent. */
   onChanged?: () => void
   onChanged?: () => void
   /** The conversation the address asks for, opened on arrival. */
   /** The conversation the address asks for, opened on arrival. */
   focus?: InboxFocus | null
   focus?: InboxFocus | null
 }) {
 }) {
   const t = useTranslations('messaging.inbox')
   const t = useTranslations('messaging.inbox')
+  const te = useTranslations('messaging.email')
   const router = useRouter()
   const router = useRouter()
   const [threads, setThreads] = useState(initialThreads)
   const [threads, setThreads] = useState(initialThreads)
   const [cursor, setCursor] = useState<string | null>(initialCursor)
   const [cursor, setCursor] = useState<string | null>(initialCursor)
@@ -208,6 +221,46 @@ export function UnifiedInbox({
     [router]
     [router]
   )
   )
 
 
+  /**
+   * The open thread as the list now has it. The list is replaced whenever
+   * the page's data reloads, and a reply that arrived meanwhile shows there
+   * first: a newer `lastAt` on the open row means the conversation has
+   * something to re-read, and that it has been read by being on screen.
+   */
+  const current = useMemo(
+    () => (selected ? (threads.find((row) => row.key === selected.key) ?? selected) : null),
+    [threads, selected]
+  )
+  const seen = useRef<{ key: string; lastAt: string } | null>(null)
+  useEffect(() => {
+    if (!current) {
+      seen.current = null
+      return
+    }
+    if (seen.current?.key !== current.key) {
+      // Opening it marked it read already.
+      seen.current = { key: current.key, lastAt: current.lastAt }
+      return
+    }
+    if (seen.current.lastAt === current.lastAt) return
+    seen.current = { key: current.key, lastAt: current.lastAt }
+    if (current.unread > 0) void markRead(current)
+  }, [current, markRead])
+
+  // A message arriving on any channel is announced to the workshop's
+  // notification feed over the socket. That is the inbox's cue to reload
+  // its data; the feed's own first fill, of older notifications, is not.
+  const latest = useNotificationStore((state) => state.notifications[0])
+  const mountedAt = useRef(Date.now())
+  const announced = useRef<string | null>(null)
+  useEffect(() => {
+    if (!latest || announced.current === latest.id) return
+    announced.current = latest.id
+    if (!latest.type.endsWith('_inbound')) return
+    if (new Date(latest.createdAt).getTime() < mountedAt.current - 60_000) return
+    onChanged?.()
+  }, [latest, onChanged])
+
   /**
   /**
    * The opposite gesture, for "come back to this later". The thread stays
    * The opposite gesture, for "come back to this later". The thread stays
    * open; it is only the marker and the pill that come back. Selecting it
    * open; it is only the marker and the pill that come back. Selecting it
@@ -236,7 +289,10 @@ export function UnifiedInbox({
     [router, t]
     [router, t]
   )
   )
 
 
-  /** SMS and Telegram hand their history over as props, so it loads on select. */
+  /**
+   * SMS and Telegram hand their history over as props, so it loads on select.
+   * WhatsApp and email load their own.
+   */
   const select = useCallback(
   const select = useCallback(
     async (thread: InboxThread) => {
     async (thread: InboxThread) => {
       setSelected(thread)
       setSelected(thread)
@@ -294,14 +350,18 @@ export function UnifiedInbox({
     setIsDeleting(true)
     setIsDeleting(true)
 
 
     // A WhatsApp thread can exist under a bare number, and those are the ones
     // A WhatsApp thread can exist under a bare number, and those are the ones
-    // most worth clearing away: test sends and wrong numbers.
-    const result = !thread.customerId
-      ? await deleteWhatsappConversationByPhone(thread.contact)
-      : thread.channel === 'sms'
-        ? await deleteConversation(thread.customerId)
-        : thread.channel === 'telegram'
-          ? await deleteTelegramConversation(thread.customerId)
-          : await deleteWhatsappConversation(thread.customerId)
+    // most worth clearing away: test sends and wrong numbers. Email threads
+    // are filed by customer or by address, and one action handles both.
+    const result =
+      thread.channel === 'email'
+        ? await deleteEmailConversation({ customerId: thread.customerId, contact: thread.contact })
+        : !thread.customerId
+          ? await deleteWhatsappConversationByPhone(thread.contact)
+          : thread.channel === 'sms'
+            ? await deleteConversation(thread.customerId)
+            : thread.channel === 'telegram'
+              ? await deleteTelegramConversation(thread.customerId)
+              : await deleteWhatsappConversation(thread.customerId)
 
 
     setIsDeleting(false)
     setIsDeleting(false)
     setDeleteTarget(null)
     setDeleteTarget(null)
@@ -316,7 +376,9 @@ export function UnifiedInbox({
 
 
   const startCompose = () => {
   const startCompose = () => {
     const options = channels.filter((channel) => INITIABLE.includes(channel))
     const options = channels.filter((channel) => INITIABLE.includes(channel))
-    if (options.length === 1) {
+    // The dialog is where a workshop on its own email vendor learns why email
+    // is not offered, so it is always shown to them, even with one choice.
+    if (options.length === 1 && emailState !== 'ownVendor') {
       setComposeChannel(options[0])
       setComposeChannel(options[0])
       return
       return
     }
     }
@@ -325,7 +387,7 @@ export function UnifiedInbox({
 
 
   const openThreadFor = (
   const openThreadFor = (
     channel: MessagingChannel,
     channel: MessagingChannel,
-    customer: { id: string; name: string; phone?: string | null }
+    customer: { id: string; name: string; phone?: string | null; email?: string | null }
   ) => {
   ) => {
     const existing = threads.find(
     const existing = threads.find(
       (thread) => thread.channel === channel && thread.customerId === customer.id
       (thread) => thread.channel === channel && thread.customerId === customer.id
@@ -336,7 +398,7 @@ export function UnifiedInbox({
         channel,
         channel,
         customerId: customer.id,
         customerId: customer.id,
         name: customer.name,
         name: customer.name,
-        contact: customer.phone ?? '',
+        contact: (channel === 'email' ? customer.email : customer.phone) ?? '',
         lastMessage: '',
         lastMessage: '',
         lastDirection: 'outbound',
         lastDirection: 'outbound',
         lastAt: new Date(0).toISOString(),
         lastAt: new Date(0).toISOString(),
@@ -346,6 +408,7 @@ export function UnifiedInbox({
   }
   }
 
 
   const composeOptions = channels.filter((channel) => INITIABLE.includes(channel))
   const composeOptions = channels.filter((channel) => INITIABLE.includes(channel))
+  const canCompose = composeOptions.length > 0 || emailState === 'ownVendor'
 
 
   return (
   return (
     <>
     <>
@@ -364,7 +427,7 @@ export function UnifiedInbox({
             </div>
             </div>
             {/* Starting a conversation used to be offered only by the empty
             {/* Starting a conversation used to be offered only by the empty
                 state, so it disappeared as soon as there was anything to read. */}
                 state, so it disappeared as soon as there was anything to read. */}
-            {composeOptions.length > 0 && (
+            {canCompose && (
               <Button size="sm" variant="ghost" className="h-7 px-2" onClick={startCompose}>
               <Button size="sm" variant="ghost" className="h-7 px-2" onClick={startCompose}>
                 <Plus className="mr-1 h-3.5 w-3.5" />
                 <Plus className="mr-1 h-3.5 w-3.5" />
                 {t('newMessage')}
                 {t('newMessage')}
@@ -389,7 +452,7 @@ export function UnifiedInbox({
               <p className="text-center text-sm text-muted-foreground">
               <p className="text-center text-sm text-muted-foreground">
                 {search ? t('noSearchResults') : t('empty')}
                 {search ? t('noSearchResults') : t('empty')}
               </p>
               </p>
-              {!search && composeOptions.length > 0 && (
+              {!search && canCompose && (
                 <Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
                 <Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
                   <Plus className="mr-1 h-3.5 w-3.5" />
                   <Plus className="mr-1 h-3.5 w-3.5" />
                   {t('newMessage')}
                   {t('newMessage')}
@@ -499,7 +562,9 @@ export function UnifiedInbox({
                 </div>
                 </div>
                 <p className="truncate text-xs text-muted-foreground">{selected.contact}</p>
                 <p className="truncate text-xs text-muted-foreground">{selected.contact}</p>
               </div>
               </div>
-              {(selected.customerId || selected.channel === 'whatsapp') && (
+              {(selected.customerId ||
+                selected.channel === 'whatsapp' ||
+                selected.channel === 'email') && (
                 <DropdownMenu>
                 <DropdownMenu>
                   <DropdownMenuTrigger asChild>
                   <DropdownMenuTrigger asChild>
                     <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
                     <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
@@ -540,6 +605,20 @@ export function UnifiedInbox({
                 onSent={onChanged}
                 onSent={onChanged}
                 className="h-auto min-h-0 flex-1"
                 className="h-auto min-h-0 flex-1"
               />
               />
+            ) : selected.channel === 'email' ? (
+              <EmailConversation
+                key={selected.key}
+                thread={{
+                  key: selected.key,
+                  customerId: selected.customerId,
+                  name: selected.name,
+                  address: selected.contact,
+                }}
+                emailState={emailState}
+                onSent={onChanged}
+                refreshKey={current?.lastAt ?? selected.lastAt}
+                className="h-auto min-h-0 flex-1"
+              />
             ) : selected.channel === 'sms' && smsData && selected.customerId ? (
             ) : selected.channel === 'sms' && smsData && selected.customerId ? (
               <SmsConversation
               <SmsConversation
                 key={selected.key}
                 key={selected.key}
@@ -570,7 +649,7 @@ export function UnifiedInbox({
           <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
           <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
             <Inbox className="mb-3 h-10 w-10 text-muted-foreground/40" />
             <Inbox className="mb-3 h-10 w-10 text-muted-foreground/40" />
             <p className="text-sm">{t('selectConversation')}</p>
             <p className="text-sm">{t('selectConversation')}</p>
-            {composeOptions.length > 0 && (
+            {canCompose && (
               <Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
               <Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
                 <Plus className="mr-1 h-3.5 w-3.5" />
                 <Plus className="mr-1 h-3.5 w-3.5" />
                 {t('newMessage')}
                 {t('newMessage')}
@@ -580,7 +659,8 @@ export function UnifiedInbox({
         )}
         )}
       </div>
       </div>
 
 
-      {/* Which channel, asked only when there is a choice to make */}
+      {/* Which channel, asked when there is a choice to make or when email
+          has to explain that it is answered elsewhere */}
       <Dialog open={showChannelChoice} onOpenChange={setShowChannelChoice}>
       <Dialog open={showChannelChoice} onOpenChange={setShowChannelChoice}>
         <DialogContent className="sm:max-w-sm">
         <DialogContent className="sm:max-w-sm">
           <DialogHeader>
           <DialogHeader>
@@ -603,6 +683,26 @@ export function UnifiedInbox({
                 {channelLabel(channel)}
                 {channelLabel(channel)}
               </Button>
               </Button>
             ))}
             ))}
+            {/* Shown, not hidden: a workshop that expects email here should
+                learn why it is missing rather than wonder. */}
+            {emailState === 'ownVendor' && (
+              <div className="space-y-1.5">
+                <Button type="button" variant="outline" className="w-full justify-start" disabled>
+                  <ChannelBadge channel="email" withIcon className="mr-2" />
+                  {channelLabel('email')}
+                </Button>
+                <p className="px-1 text-xs text-muted-foreground">
+                  {te('ownVendorChoice')}
+                  <Link
+                    href="/settings/integrations"
+                    className="ml-1 font-medium text-primary hover:underline"
+                    onClick={() => setShowChannelChoice(false)}
+                  >
+                    {te('ownVendorSettings')}
+                  </Link>
+                </p>
+              </div>
+            )}
           </div>
           </div>
         </DialogContent>
         </DialogContent>
       </Dialog>
       </Dialog>
@@ -644,6 +744,12 @@ export function UnifiedInbox({
         onOpenChange={(open) => !open && setComposeChannel(null)}
         onOpenChange={(open) => !open && setComposeChannel(null)}
         onSelect={(recipient) => openThreadFor('whatsapp', recipient)}
         onSelect={(recipient) => openThreadFor('whatsapp', recipient)}
       />
       />
+
+      <NewEmailDialog
+        open={composeChannel === 'email'}
+        onOpenChange={(open) => !open && setComposeChannel(null)}
+        onSelect={(recipient) => openThreadFor('email', recipient)}
+      />
     </>
     </>
   )
   )
 }
 }

+ 56 - 0
src/features/messaging/Lib/emailAttachments.server.ts

@@ -0,0 +1,56 @@
+import 'server-only'
+
+import { randomUUID } from 'node:crypto'
+import { mkdir, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { orgUploadDir } from '@/lib/upload-root'
+import { extensionForType } from '@/lib/upload-url'
+import { cleanAttachmentName, type IncomingFile, selectAttachments } from './inboundMail'
+
+/** The upload folder inbound mail attachments are written to. */
+export const EMAIL_UPLOAD_FOLDER = 'email'
+
+export interface StoredEmailAttachment {
+  fileName: string
+  contentType: string
+  size: number
+  url: string
+}
+
+export interface AttachmentToStore extends IncomingFile {
+  content: Buffer
+}
+
+/**
+ * Writes the kept files of a mail into the workshop's uploads, the way the
+ * upload route does: a random name under the workshop's own folder, served
+ * back through the protected file route to members of that workshop only.
+ * The rows pointing at them are written by the caller; a file whose row
+ * never gets written is an orphan the nightly sweep trashes.
+ */
+export async function storeEmailAttachments(
+  organizationId: string,
+  files: readonly AttachmentToStore[]
+): Promise<StoredEmailAttachment[]> {
+  const kept = selectAttachments(files)
+  if (kept.length === 0) return []
+
+  const dir = orgUploadDir(organizationId, EMAIL_UPLOAD_FOLDER)
+  await mkdir(dir, { recursive: true })
+
+  const stored: StoredEmailAttachment[] = []
+  for (const index of kept) {
+    const file = files[index]
+    const contentType = file.contentType.toLowerCase()
+    const extension = extensionForType(contentType)
+    const name = `${randomUUID()}.${extension}`
+    await writeFile(path.join(dir, name), file.content)
+    stored.push({
+      fileName: cleanAttachmentName(file.filename, extension),
+      contentType,
+      size: file.content.length,
+      url: `/api/protected/files/${organizationId}/${EMAIL_UPLOAD_FOLDER}/${name}`,
+    })
+  }
+  return stored
+}

+ 23 - 0
src/features/messaging/Lib/emailChannel.server.ts

@@ -0,0 +1,23 @@
+import 'server-only'
+
+import { canEmailCustomers } from '@/features/email/Lib/emailAllowance'
+import { usesPlatformMail } from '@/lib/email'
+import { replyMailEnabled } from '@/lib/reply-mail'
+
+/**
+ * Whether the inbox has an email channel for this workshop.
+ *
+ * - `active`: the cloud instance, and the workshop sends through the
+ *   platform's address, so replies come back to the inbox.
+ * - `ownVendor`: the cloud instance, but the workshop connected its own mail
+ *   vendor. Its mail leaves from its own address and replies go there; the
+ *   inbox shows the channel disabled with that reason, and any history.
+ * - `off`: not the cloud instance. The channel is nowhere to be seen.
+ */
+export type EmailChannelState = 'active' | 'ownVendor' | 'off'
+
+export async function emailChannelState(organizationId: string): Promise<EmailChannelState> {
+  if (!replyMailEnabled()) return 'off'
+  if (!(await usesPlatformMail(organizationId))) return 'ownVendor'
+  return (await canEmailCustomers(organizationId)) ? 'active' : 'off'
+}

+ 28 - 0
src/features/messaging/Lib/emailClaim.server.ts

@@ -0,0 +1,28 @@
+import 'server-only'
+
+import { db } from '@/lib/db'
+import { normalizeEmailAddress } from '@/lib/email-address'
+
+/**
+ * Gives a customer the email thread that was waiting for them.
+ *
+ * A first mail from a stranger is filed under the address alone, and the
+ * workshop's answer to it is to create the customer. Without this the thread
+ * would stay a read-only history under the address while the new customer
+ * started an empty one: the rows are moved under the customer instead, so
+ * the conversation continues where it was, with a reply box.
+ */
+export async function claimEmailMessagesForCustomer(
+  organizationId: string,
+  customerId: string,
+  email: string | null | undefined
+): Promise<{ claimed: number }> {
+  const address = normalizeEmailAddress(email)
+  if (!address) return { claimed: 0 }
+
+  const { count } = await db.emailMessage.updateMany({
+    where: { organizationId, customerId: null, address: { equals: address, mode: 'insensitive' } },
+    data: { customerId },
+  })
+  return { claimed: count }
+}

+ 241 - 0
src/features/messaging/Lib/inboundMail.server.ts

@@ -0,0 +1,241 @@
+import 'server-only'
+
+import { type ParsedMail, simpleParser } from 'mailparser'
+import { db } from '@/lib/db'
+import { normalizeEmailAddress } from '@/lib/email-address'
+import { notify } from '@/lib/notify'
+import {
+  type InboundMailConfig,
+  normalizeMessageId,
+  parseReferences,
+  replyTokenFromRecipients,
+} from '@/lib/reply-mail'
+import { organizationForReplyToken } from '@/lib/reply-mail.server'
+import { type AttachmentToStore, storeEmailAttachments } from './emailAttachments.server'
+import {
+  cleanBody,
+  cleanSubject,
+  headerView,
+  isAutomatedMail,
+  MAX_MAIL_BYTES,
+  type SesReceivedMail,
+} from './inboundMail'
+
+/**
+ * One inbound mail, from the receiving service's notification to a row in
+ * the workshop's inbox.
+ *
+ * Everything that decides whether a mail is filed happens here, in order:
+ * whose it is (the reply token in the recipient address), whether it was
+ * already filed (the service's own id is unique), whether it is safe (the
+ * virus verdict), whether a person wrote it (the auto-reply headers), and
+ * who the person is (the sender's address against the workshop's
+ * customers). A mail that fails a rule is dropped with a reason and the
+ * delivery is acknowledged, since redelivering it would fail the same
+ * rule. Only a failure to fetch or store is thrown, so the service retries.
+ */
+
+export interface StoredObject {
+  size: number
+  /** Reads the bytes; called only when the size is within the limit. */
+  read(): Promise<Buffer>
+}
+
+export interface ReceiveDeps {
+  config: InboundMailConfig
+  fetchObject(bucket: string, key: string): Promise<StoredObject>
+  parse?(raw: Buffer): Promise<ParsedMail>
+  store?(
+    organizationId: string,
+    files: readonly AttachmentToStore[]
+  ): ReturnType<typeof storeEmailAttachments>
+  notify?: typeof notify
+}
+
+export type ReceiveOutcome =
+  | { outcome: 'filed'; id: string; organizationId: string }
+  | { outcome: 'dropped'; reason: string }
+
+/** The sender's address and display name as the parser read them. */
+function senderOf(parsed: ParsedMail): { address: string | null; name: string | null } {
+  const first = parsed.from?.value?.[0]
+  const address = normalizeEmailAddress(first?.address ?? null)
+  const name =
+    first?.name
+      ?.replace(/[\r\n\t]+/g, ' ')
+      .trim()
+      .slice(0, 200) || null
+  return { address, name }
+}
+
+export async function receiveSesMail(
+  mail: SesReceivedMail,
+  deps: ReceiveDeps
+): Promise<ReceiveOutcome> {
+  const { config } = deps
+  const parse = deps.parse ?? ((raw: Buffer) => simpleParser(raw))
+  const store = deps.store ?? storeEmailAttachments
+  const announce = deps.notify ?? notify
+
+  // Whose mail this is. The recipient list is the envelope's, which the
+  // receiving service fills in itself; a To header can say anything.
+  const token = replyTokenFromRecipients(mail.recipients, config.domain)
+  if (!token) return { outcome: 'dropped', reason: 'no reply address among recipients' }
+  const organization = await organizationForReplyToken(token)
+  if (!organization) return { outcome: 'dropped', reason: 'reply token belongs to no workshop' }
+
+  if (mail.bucket !== config.bucket) {
+    return { outcome: 'dropped', reason: `stored in an unexpected bucket ${mail.bucket}` }
+  }
+
+  // Notifications are delivered at least once. The service's id is unique,
+  // so a second delivery finds the first filing and stops.
+  const already = await db.emailMessage.findUnique({
+    where: { providerMessageId: mail.messageId },
+    select: { id: true },
+  })
+  if (already) return { outcome: 'dropped', reason: 'already filed' }
+
+  if (mail.virusVerdict === 'FAIL') return { outcome: 'dropped', reason: 'virus verdict FAIL' }
+
+  // From here on a failure is the fetch or the database, which a retry can
+  // fix, so errors propagate.
+  const object = await deps.fetchObject(mail.bucket, mail.objectKey)
+
+  let parsed: ParsedMail | null = null
+  if (object.size <= MAX_MAIL_BYTES) {
+    parsed = await parse(await object.read())
+  }
+
+  const headers = headerView(parsed?.headers)
+  const sender = parsed ? senderOf(parsed) : { address: null, name: null }
+
+  if (parsed && isAutomatedMail(headers, sender.address)) {
+    return { outcome: 'dropped', reason: 'automated mail' }
+  }
+  if (!sender.address) {
+    return { outcome: 'dropped', reason: parsed ? 'no sender address' : 'mail too large to read' }
+  }
+  // Our own mail bouncing back through the reply domain, or a workshop
+  // writing to its own reply address, is not a customer conversation.
+  if (sender.address.endsWith(`@${config.domain}`)) {
+    return { outcome: 'dropped', reason: 'sent from the reply domain' }
+  }
+
+  const [customer, answered] = await Promise.all([
+    db.customer.findFirst({
+      where: {
+        organizationId: organization.id,
+        email: { equals: sender.address, mode: 'insensitive' },
+      },
+      orderBy: { updatedAt: 'desc' },
+      select: { id: true, name: true },
+    }),
+    answeredMessage(organization.id, parsed),
+  ])
+
+  const attachments = parsed
+    ? await store(
+        organization.id,
+        parsed.attachments
+          .filter((attachment) => Buffer.isBuffer(attachment.content))
+          .map((attachment) => ({
+            filename: attachment.filename,
+            contentType: attachment.contentType,
+            size: attachment.size,
+            content: attachment.content,
+          }))
+      )
+    : []
+
+  const subject = cleanSubject(parsed?.subject)
+  const body = cleanBody(parsed?.text)
+
+  let id: string
+  /** The customer whose thread the row went into, if any. */
+  let filedUnder: string | null
+  try {
+    const row = await db.emailMessage.create({
+      data: {
+        direction: 'inbound',
+        kind: 'message',
+        address: sender.address,
+        fromName: sender.name,
+        subject,
+        body,
+        messageId: normalizeMessageId(parsed?.messageId),
+        inReplyTo: answered?.messageId ?? normalizeMessageId(parsed?.inReplyTo),
+        providerMessageId: mail.messageId,
+        spamVerdict: mail.spamVerdict,
+        status: 'received',
+        errorMessage: parsed ? null : 'Message exceeded 30 MB; its contents were not kept',
+        relatedEntityType: answered?.relatedEntityType ?? null,
+        relatedEntityId: answered?.relatedEntityId ?? null,
+        organizationId: organization.id,
+        // The address decides the customer; the mail being answered only
+        // fills in when the address matches nobody, as when a customer
+        // answers from a second address.
+        customerId: customer?.id ?? answered?.customerId ?? null,
+        attachments: attachments.length > 0 ? { create: attachments } : undefined,
+      },
+      select: { id: true, customerId: true },
+    })
+    id = row.id
+    filedUnder = row.customerId
+  } catch (error) {
+    // Two deliveries racing past the check above: the unique id lets one in.
+    if ((error as { code?: string }).code === 'P2002') {
+      return { outcome: 'dropped', reason: 'already filed' }
+    }
+    throw error
+  }
+
+  const who = customer?.name ?? sender.name ?? sender.address
+  const preview = subject ?? body.slice(0, 100)
+  const contact = encodeURIComponent(sender.address)
+  await announce({
+    organizationId: organization.id,
+    type: 'email_inbound',
+    title: 'New email received',
+    message: `${who}: ${preview}`.slice(0, 200),
+    entityType: 'email_message',
+    entityId: id,
+    // Where the row went, which is the customer's thread also when only the
+    // mail answered named the customer; the address alone would open an
+    // empty one.
+    entityUrl: filedUnder
+      ? `/messages?channel=email&customerId=${filedUnder}`
+      : `/messages?channel=email&contact=${contact}`,
+  })
+
+  return { outcome: 'filed', id, organizationId: organization.id }
+}
+
+/**
+ * The mail of ours this one answers, by the ids a reply carries. In-Reply-To
+ * names the mail directly; References lists the whole conversation, newest
+ * last, for clients that set only that.
+ */
+async function answeredMessage(organizationId: string, parsed: ParsedMail | null) {
+  if (!parsed) return null
+  const ids = [
+    normalizeMessageId(parsed.inReplyTo),
+    ...parseReferences(parsed.references).reverse(),
+  ].filter((id): id is string => id !== null)
+  if (ids.length === 0) return null
+
+  const candidates = await db.emailMessage.findMany({
+    where: { organizationId, direction: 'outbound', messageId: { in: ids.slice(0, 20) } },
+    select: {
+      messageId: true,
+      customerId: true,
+      relatedEntityType: true,
+      relatedEntityId: true,
+    },
+  })
+  for (const id of ids) {
+    const match = candidates.find((candidate) => candidate.messageId === id)
+    if (match) return match
+  }
+  return null
+}

+ 290 - 0
src/features/messaging/Lib/inboundMail.ts

@@ -0,0 +1,290 @@
+/**
+ * What an inbound email is, before anything is stored: the shape of the
+ * receiving service's notification, which mails are machines talking rather
+ * than customers, how a reply is separated from the mail it quotes, and
+ * which files are kept. No I/O, so every rule here has a test.
+ */
+
+/** A mail larger than this is filed without its contents. */
+export const MAX_MAIL_BYTES = 30 * 1024 * 1024
+/** Text beyond this is cut; a customer's reply is never this long, a forwarded newsletter is. */
+export const MAX_BODY_CHARS = 100_000
+export const MAX_ATTACHMENTS = 10
+export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024
+export const MAX_TOTAL_ATTACHMENT_BYTES = 25 * 1024 * 1024
+
+/**
+ * Files kept from a mail, by type. These are the types the file route
+ * serves with a content type of their own, so what is kept can be opened.
+ * Anything else (an archive, a program, an HTML page) is left out.
+ */
+export const KEPT_ATTACHMENT_TYPES: ReadonlySet<string> = new Set([
+  'image/jpeg',
+  'image/png',
+  'image/webp',
+  'image/avif',
+  'image/gif',
+  'application/pdf',
+  'text/plain',
+  'text/csv',
+])
+
+// ─── The receiving service's notification ───────────────────────────────────
+
+export interface SesReceivedMail {
+  /** The receiving service's id for this mail, also the stored object's key. */
+  messageId: string
+  bucket: string
+  objectKey: string
+  recipients: string[]
+  spamVerdict: string | null
+  virusVerdict: string | null
+}
+
+const isStringArray = (value: unknown): value is string[] =>
+  Array.isArray(value) && value.every((item) => typeof item === 'string')
+
+function verdict(value: unknown): string | null {
+  if (!value || typeof value !== 'object') return null
+  const status = (value as { status?: unknown }).status
+  return typeof status === 'string' ? status : null
+}
+
+/**
+ * The parts of an SES "Received" notification the app acts on, or null for
+ * anything that is not one: a different notification type, a receipt rule
+ * action other than storing to S3, or a shape with something missing.
+ */
+export function parseSesReceivedNotification(input: unknown): SesReceivedMail | null {
+  if (!input || typeof input !== 'object') return null
+  const raw = input as Record<string, unknown>
+  if (raw.notificationType !== 'Received') return null
+
+  const mail = raw.mail as Record<string, unknown> | undefined
+  const receipt = raw.receipt as Record<string, unknown> | undefined
+  if (!mail || typeof mail !== 'object' || !receipt || typeof receipt !== 'object') return null
+
+  const messageId = mail.messageId
+  if (typeof messageId !== 'string' || !/^[A-Za-z0-9._-]{1,200}$/.test(messageId)) return null
+
+  const recipients = receipt.recipients
+  if (!isStringArray(recipients) || recipients.length === 0) return null
+
+  const action = receipt.action as Record<string, unknown> | undefined
+  if (!action || action.type !== 'S3') return null
+  const bucket = action.bucketName
+  const objectKey = action.objectKey
+  if (typeof bucket !== 'string' || !bucket) return null
+  if (typeof objectKey !== 'string' || !objectKey || objectKey.length > 1024) return null
+
+  return {
+    messageId,
+    bucket,
+    objectKey,
+    recipients,
+    spamVerdict: verdict(receipt.spamVerdict),
+    virusVerdict: verdict(receipt.virusVerdict),
+  }
+}
+
+// ─── Machines writing, not customers ────────────────────────────────────────
+
+export interface MailHeaderView {
+  /** A header's value, lower-cased, or null when it is not present. */
+  get(name: string): string | null
+}
+
+/** A header map from the parser's, or from a plain record in a test. */
+export function headerView(
+  headers: Map<string, unknown> | Record<string, unknown> | undefined
+): MailHeaderView {
+  const read = (name: string): unknown => {
+    if (!headers) return undefined
+    if (headers instanceof Map) return headers.get(name.toLowerCase())
+    return headers[name.toLowerCase()] ?? headers[name]
+  }
+  return {
+    get(name) {
+      const value = read(name)
+      if (value === undefined || value === null) return null
+      if (typeof value === 'string') return value.toLowerCase()
+      if (Array.isArray(value)) return value.map(String).join(', ').toLowerCase()
+      if (typeof value === 'object') {
+        const inner = value as { value?: unknown; text?: unknown }
+        if (typeof inner.value === 'string') return inner.value.toLowerCase()
+        if (typeof inner.text === 'string') return inner.text.toLowerCase()
+      }
+      return String(value).toLowerCase()
+    },
+  }
+}
+
+const SYSTEM_SENDER =
+  /^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounces?|notifications?)(\+|@|-|_|\.|$)/i
+
+/**
+ * Whether a mail was generated rather than written: an out-of-office
+ * answer, a bounce, a list mailing. Filing these would put a thread in the
+ * inbox for every holiday auto-reply to an invoice run, so they are dropped
+ * on the headers mail systems set for exactly this purpose.
+ */
+export function isAutomatedMail(headers: MailHeaderView, fromAddress: string | null): boolean {
+  const autoSubmitted = headers.get('auto-submitted')
+  if (autoSubmitted && autoSubmitted !== 'no') return true
+  // Exchange puts All on the mail it generates. A sender can also set the
+  // header on ordinary mail to decline receipts (DR, RN, NRN) or to say
+  // None, so only the values that mark the mail itself as generated count.
+  const suppress = headers.get('x-auto-response-suppress')
+  if (suppress && /\b(all|oof|autoreply)\b/.test(suppress)) return true
+  if (headers.get('x-autoreply') !== null || headers.get('x-autorespond') !== null) return true
+
+  const precedence = headers.get('precedence')
+  if (precedence && /\b(bulk|junk|list|auto_reply)\b/.test(precedence)) return true
+
+  const contentType = headers.get('content-type')
+  if (contentType?.startsWith('multipart/report')) return true
+
+  if (headers.get('list-id') !== null || headers.get('list-unsubscribe') !== null) return true
+
+  if (fromAddress && SYSTEM_SENDER.test(fromAddress)) return true
+  return false
+}
+
+// ─── The reply and what it quotes ───────────────────────────────────────────
+
+/**
+ * The line a mail client writes above the quoted mail, in the languages the
+ * app speaks: "On <date>, <who> wrote:" and its equivalents. The verb sits
+ * at the end in English and in the middle in Norwegian or German, so the
+ * line is recognised by the verb, the colon it ends with, and the date or
+ * address it always carries; a sentence like "he wrote:" has neither.
+ * Clients wrap the line, so it is also tried joined with the one after it.
+ */
+const ATTRIBUTION =
+  /^(?=.*[\d@]).{0,400}?(?<!\p{L})(?:wrote|skrev|schrieb|écrit|escribió|escribio|scritto|schreef|napisał(?:\(a\)|a)?|escreveu|rašė|написал[а]?|написав|yazdı)(?!\p{L}).{0,300}?:\s*$/iu
+
+/** Outlook's separators above a quoted mail, in a few spellings. */
+const SEPARATOR =
+  /^\s*(?:-{3,}\s*(?:original message|opprinnelig melding|ursprüngliche nachricht|message d'origine|mensaje original|messaggio originale|oorspronkelijk bericht|wiadomość oryginalna|mensagem original|исходное сообщение|orijinal ileti)\s*-{3,}|_{10,})\s*$/i
+
+/** Outlook's header block when it has no separator line. */
+const HEADER_BLOCK = /^(?:from|fra|von|de|da|van|od|от|kimden|nuo)\s*:\s.+$/i
+const HEADER_FOLLOW =
+  /^(?:sent|date|to|subject|sendt|til|emne|gesendet|an|betreff|envoyé|à|objet|enviado|para|asunto|assunto|inviato|a|oggetto|verzonden|aan|onderwerp|wysłano|do|temat|отправлено|кому|тема|gönderildi|kime|konu)\s*:\s/i
+
+function isAttribution(lines: string[], index: number): boolean {
+  const line = lines[index].trim()
+  if (!line) return false
+  if (ATTRIBUTION.test(line)) return true
+  const next = lines[index + 1]?.trim()
+  if (!next || next.startsWith('>')) return false
+  return ATTRIBUTION.test(`${line} ${next}`)
+}
+
+function isHeaderBlock(lines: string[], index: number): boolean {
+  if (!HEADER_BLOCK.test(lines[index].trim())) return false
+  const next = lines[index + 1]?.trim() ?? ''
+  return HEADER_FOLLOW.test(next)
+}
+
+/**
+ * The part of a mail the sender wrote, and the part their client quoted
+ * underneath. Quoted lines, the attribution line above them and a signature
+ * after a `-- ` line are the quoted part; everything up to the first of
+ * them is the reply. A mail with nothing before its quote is returned whole,
+ * since the sender's words are in there somewhere.
+ */
+export function splitQuotedReply(text: string): { reply: string; quoted: string } {
+  const lines = text.replace(/\r\n?/g, '\n').split('\n')
+  let cut = lines.length
+
+  for (let index = 0; index < lines.length; index++) {
+    const line = lines[index]
+    if (line.startsWith('>') || isAttribution(lines, index) || SEPARATOR.test(line)) {
+      cut = index
+      break
+    }
+    if (isHeaderBlock(lines, index)) {
+      cut = index
+      break
+    }
+    if (line === '-- ' || line === '--') {
+      cut = index
+      break
+    }
+  }
+
+  const reply = lines.slice(0, cut).join('\n').trim()
+  const quoted = lines.slice(cut).join('\n').trim()
+  if (!reply) return { reply: text.trim(), quoted: '' }
+  return { reply, quoted }
+}
+
+// ─── Files ──────────────────────────────────────────────────────────────────
+
+/**
+ * A file name fit to show and to put in a download header: one line, no
+ * path, no control characters, at most 120 characters, and the extension
+ * the stored file actually has.
+ */
+export function cleanAttachmentName(name: string | null | undefined, extension: string): string {
+  const base = (name ?? '')
+    .replace(/[\\/]+/g, ' ')
+    .replace(/\p{Cc}+/gu, ' ')
+    .replace(/["<>]+/g, '')
+    .replace(/\s+/g, ' ')
+    .trim()
+    .slice(0, 120)
+  if (!base) return `attachment.${extension}`
+  return base.toLowerCase().endsWith(`.${extension}`) ? base : `${base}.${extension}`
+}
+
+export interface IncomingFile {
+  filename?: string | null
+  contentType: string
+  size: number
+}
+
+/**
+ * Which of a mail's files are kept: those of a kept type, within the size
+ * limits, up to the count limit, in the order they came. Returns the indexes
+ * so the caller can keep its own objects.
+ */
+export function selectAttachments(files: readonly IncomingFile[]): number[] {
+  const kept: number[] = []
+  let total = 0
+  for (let index = 0; index < files.length; index++) {
+    const file = files[index]
+    if (kept.length >= MAX_ATTACHMENTS) break
+    if (!KEPT_ATTACHMENT_TYPES.has(file.contentType.toLowerCase())) continue
+    if (file.size <= 0 || file.size > MAX_ATTACHMENT_BYTES) continue
+    if (total + file.size > MAX_TOTAL_ATTACHMENT_BYTES) continue
+    total += file.size
+    kept.push(index)
+  }
+  return kept
+}
+
+/** A subject fit to store: one line, at most 998 characters, or null. */
+export function cleanSubject(subject: string | null | undefined): string | null {
+  const cleaned = (subject ?? '')
+    .replace(/[\r\n\t]+/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim()
+  return cleaned ? cleaned.slice(0, 998) : null
+}
+
+/** `Re: <subject>`, unless it already is one. */
+export function replySubject(subject: string | null | undefined): string {
+  const base = cleanSubject(subject) ?? ''
+  if (!base) return 'Re:'
+  return /^(re|sv|aw|antw|res|odp|ynt|atb|ответ)\s*:/i.test(base) ? base : `Re: ${base}`
+}
+
+/** Body text as stored: normalised line endings, no trailing space, cut to the limit. */
+export function cleanBody(text: string | null | undefined): string {
+  // A NUL byte cannot be stored in a text column, so it goes; other control
+  // characters are left alone, since tabs and line breaks are text.
+  const normalized = (text ?? '').replace(/\r\n?/g, '\n').split('\u0000').join('').trimEnd()
+  return normalized.length > MAX_BODY_CHARS ? normalized.slice(0, MAX_BODY_CHARS) : normalized
+}

+ 106 - 0
src/features/messaging/Lib/outboundMail.server.ts

@@ -0,0 +1,106 @@
+import 'server-only'
+
+import { db } from '@/lib/db'
+import { platformFromAddressAs, type SendMailOptions, usesPlatformMail } from '@/lib/email'
+import { normalizeEmailAddress } from '@/lib/email-address'
+import { newMailMessageId, replyMailAddress, replyMailDomain } from '@/lib/reply-mail'
+import { ensureReplyMailToken } from '@/lib/reply-mail.server'
+
+/**
+ * What a customer mail needs so that the answer comes back to the inbox.
+ *
+ * On the cloud instance, a workshop sending through the platform's address
+ * gets three things on every customer mail: the workshop's own name as the
+ * sender's name, a Reply-To of `reply-<token>@<reply domain>`, and a
+ * Message-ID of ours. The mail is then recorded, so a reply has something to
+ * answer and the thread shows what was sent. Everywhere else (a self-hosted
+ * install, a workshop with its own mail vendor) this does nothing at all:
+ * the mail goes out as it always has and replies reach the workshop's own
+ * mailbox.
+ */
+
+export interface CustomerThread {
+  customerId?: string | null
+  /**
+   * `message` for something written to the customer in the inbox, which
+   * lists the thread; `document` for an invoice, a quote, a reminder or any
+   * other mail the app composes, kept for context and listed only once the
+   * customer answers.
+   */
+  kind: 'message' | 'document'
+  relatedEntityType?: string | null
+  relatedEntityId?: string | null
+  /** The customer's Message-ID when this is a reply to their mail. */
+  inReplyTo?: string | null
+  references?: string[]
+}
+
+export interface PreparedCustomerMail {
+  headers: Pick<SendMailOptions, 'from' | 'replyTo' | 'messageId' | 'inReplyTo' | 'references'>
+  /** Files the mail as sent, or as failed with why. Returns the row's id. */
+  record(outcome: {
+    status: 'sent' | 'failed'
+    subject: string
+    body: string
+    errorMessage?: string | null
+  }): Promise<string>
+}
+
+export async function prepareCustomerMail(
+  organizationId: string,
+  to: string,
+  thread: CustomerThread
+): Promise<PreparedCustomerMail | null> {
+  const domain = replyMailDomain()
+  if (!domain) return null
+  if (!(await usesPlatformMail(organizationId))) return null
+
+  const organization = await db.organization.findUnique({
+    where: { id: organizationId },
+    select: { name: true },
+  })
+  if (!organization) return null
+
+  const [token, from] = await Promise.all([
+    ensureReplyMailToken(organizationId),
+    platformFromAddressAs(organization.name),
+  ])
+  const messageId = newMailMessageId(domain)
+  const address = normalizeEmailAddress(to) ?? to.trim().toLowerCase()
+
+  return {
+    headers: {
+      from,
+      replyTo: replyMailAddress(token, domain),
+      messageId,
+      inReplyTo: thread.inReplyTo ?? undefined,
+      // Our id goes in References as well as Message-ID. Amazon SES, which
+      // the platform sends through, replaces Message-ID with one of its own
+      // on the wire but leaves References alone, and a reply carries every
+      // reference back; so this is how a reply is matched to the mail it
+      // answers whatever the provider did to the Message-ID.
+      references: [...(thread.references ?? []), messageId],
+    },
+    async record(outcome) {
+      const row = await db.emailMessage.create({
+        data: {
+          direction: 'outbound',
+          kind: thread.kind,
+          address,
+          subject: outcome.subject.slice(0, 998),
+          body: outcome.body,
+          messageId,
+          inReplyTo: thread.inReplyTo ?? null,
+          status: outcome.status,
+          errorMessage: outcome.errorMessage?.slice(0, 500) ?? null,
+          relatedEntityType: thread.relatedEntityType ?? null,
+          relatedEntityId: thread.relatedEntityId ?? null,
+          organizationId,
+          customerId: thread.customerId ?? null,
+        },
+        select: { id: true },
+      })
+      return row.id
+    },
+  }
+}

+ 5 - 4
src/features/messaging/Lib/unreadCount.ts

@@ -15,13 +15,14 @@ export async function countUnreadMessages(organizationId: string): Promise<numbe
   const unread = { direction: 'inbound', readAt: null }
   const unread = { direction: 'inbound', readAt: null }
   // Only what the inbox lists: an SMS or Telegram message from a chat matched
   // Only what the inbox lists: an SMS or Telegram message from a chat matched
   // to no customer has no thread to open, so it could never be marked read
   // to no customer has no thread to open, so it could never be marked read
-  // and the pill would say 2 forever. WhatsApp threads are listed under the
-  // bare number, so every WhatsApp message counts.
+  // and the pill would say 2 forever. WhatsApp and email threads are listed
+  // under the bare number or address, so every one of those messages counts.
   const listed = { customerId: { not: null } }
   const listed = { customerId: { not: null } }
-  const [sms, telegram, whatsapp] = await Promise.all([
+  const [sms, telegram, whatsapp, email] = await Promise.all([
     db.smsMessage.count({ where: { organizationId, ...unread, ...listed } }),
     db.smsMessage.count({ where: { organizationId, ...unread, ...listed } }),
     db.telegramMessage.count({ where: { organizationId, ...unread, ...listed } }),
     db.telegramMessage.count({ where: { organizationId, ...unread, ...listed } }),
     db.whatsappMessage.count({ where: { organizationId, ...unread } }),
     db.whatsappMessage.count({ where: { organizationId, ...unread } }),
+    db.emailMessage.count({ where: { organizationId, ...unread } }),
   ])
   ])
-  return sms + telegram + whatsapp
+  return sms + telegram + whatsapp + email
 }
 }

+ 8 - 0
src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts

@@ -47,6 +47,14 @@ export async function dispatchScheduledMessage(message: DispatchableMessage): Pr
         to,
         to,
         subject: message.subject?.trim() || undefined,
         subject: message.subject?.trim() || undefined,
         context: { message: message.body, customerName: customer?.name },
         context: { message: message.body, customerName: customer?.name },
+        // Kept for context, not listed: a reminder campaign mails many
+        // customers at once, and the inbox should show the ones who answer.
+        thread: {
+          customerId: customer?.id,
+          kind: 'document',
+          relatedEntityType: 'ScheduledMessage',
+          relatedEntityId: message.id,
+        },
       })
       })
       return
       return
     }
     }

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

@@ -184,6 +184,21 @@ export const BACKUP_ENTITIES: readonly BackupEntity[] = [
     restore: 'replace',
     restore: 'replace',
     clearOrder: 12,
     clearOrder: 12,
   },
   },
+  // Mail to and from customers is part of the same conversation history as
+  // the other channels, so it rides the same checkbox.
+  {
+    model: 'EmailMessage',
+    key: 'emailMessages',
+    option: 'smsMessages',
+    restore: 'replace',
+    clearOrder: 14,
+  },
+  {
+    model: 'EmailAttachment',
+    option: 'smsMessages',
+    nestedUnder: 'EmailMessage',
+    restore: 'replace',
+  },
   {
   {
     model: 'ScheduledMessage',
     model: 'ScheduledMessage',
     key: 'scheduledMessages',
     key: 'scheduledMessages',
@@ -403,6 +418,7 @@ export const UPLOAD_CATEGORIES: readonly string[] = [
   'quotes',
   'quotes',
   'tire-hotel',
   'tire-hotel',
   'portal',
   'portal',
+  'email',
 ]
 ]
 
 
 /** Entities written at the top level of the backup, with their key. */
 /** Entities written at the top level of the backup, with their key. */

+ 51 - 0
src/lib/email-address.ts

@@ -0,0 +1,51 @@
+/**
+ * Mail addresses as header values, without the header injection.
+ *
+ * A display name is whatever someone typed into a settings page, and it is
+ * going into a mail header. Quotes, angle brackets and line breaks are
+ * stripped rather than escaped: the name is a courtesy, the address is what
+ * matters, and a mangled name beats a header the mailer refuses or a second
+ * header smuggled in on a new line.
+ */
+
+const ADDRESS = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/
+
+/** A display name fit for a header: one line, no quoting characters, at most 120 characters. */
+export function cleanDisplayName(name: string | null | undefined): string {
+  return (name ?? '')
+    .replace(/[\r\n"<>,;\\]+/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim()
+    .slice(0, 120)
+}
+
+/** `"Name" <address>`, or the bare address when the name is empty. */
+export function formatMailAddress(name: string | null | undefined, email: string): string {
+  const cleaned = cleanDisplayName(name)
+  return cleaned ? `"${cleaned}" <${email}>` : email
+}
+
+/** The two halves of `Name <address>` or of a bare address; null when it is neither. */
+export function parseMailAddress(value: string): { name: string; email: string } | null {
+  const trimmed = value.trim()
+  const angled = /^(.*?)<([^<>]+)>$/.exec(trimmed)
+  if (angled) {
+    const email = angled[2].trim()
+    if (!ADDRESS.test(email)) return null
+    const name = angled[1]
+      .trim()
+      .replace(/^"(.*)"$/, '$1')
+      .trim()
+    return { name, email }
+  }
+  return ADDRESS.test(trimmed) ? { name: '', email: trimmed } : null
+}
+
+/** A lower-cased address with no name around it, or null when it is not an address. */
+export function normalizeEmailAddress(value: string | null | undefined): string | null {
+  if (!value) return null
+  const parsed = parseMailAddress(value)
+  if (!parsed) return null
+  const email = parsed.email.toLowerCase()
+  return email.length <= 254 ? email : null
+}

+ 68 - 0
src/lib/email.ts

@@ -13,6 +13,7 @@ import {
 } from '@/features/integrations/Lib/messaging'
 } from '@/features/integrations/Lib/messaging'
 import { messagingProvider } from '@/integrations/messaging/catalog'
 import { messagingProvider } from '@/integrations/messaging/catalog'
 import { db } from './db'
 import { db } from './db'
+import { formatMailAddress, parseMailAddress } from './email-address'
 import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
 import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
 import { ORG_EMAIL_KEYS } from '@/features/email/Schema/emailSettingsSchema'
 import { ORG_EMAIL_KEYS } from '@/features/email/Schema/emailSettingsSchema'
 
 
@@ -40,6 +41,37 @@ export interface SendMailOptions {
     filename: string
     filename: string
     content: Buffer
     content: Buffer
   }[]
   }[]
+  /**
+   * Threading headers, for a mail a customer may answer. The Message-ID is
+   * ours (without angle brackets) so a reply's In-Reply-To names something
+   * we stored; In-Reply-To and References point a reply of ours at the
+   * customer's mail so their client files it in the same conversation.
+   */
+  messageId?: string
+  inReplyTo?: string
+  references?: string[]
+}
+
+const bracketed = (id: string) => (id.startsWith('<') ? id : `<${id}>`)
+
+/** The threading headers as a mail API wants them, with angle brackets on. */
+function threadHeaders(options: SendMailOptions): Record<string, string> {
+  const headers: Record<string, string> = {}
+  if (options.messageId) headers['Message-ID'] = bracketed(options.messageId)
+  if (options.inReplyTo) headers['In-Reply-To'] = bracketed(options.inReplyTo)
+  if (options.references?.length) {
+    headers.References = options.references.map(bracketed).join(' ')
+  }
+  return headers
+}
+
+/** The same headers in nodemailer's own fields. */
+function nodemailerThreading(options: SendMailOptions) {
+  return {
+    messageId: options.messageId ? bracketed(options.messageId) : undefined,
+    inReplyTo: options.inReplyTo ? bracketed(options.inReplyTo) : undefined,
+    references: options.references?.length ? options.references.map(bracketed) : undefined,
+  }
 }
 }
 
 
 // ─── Settings map helper ────────────────────────────────────────────────────
 // ─── Settings map helper ────────────────────────────────────────────────────
@@ -216,6 +248,7 @@ async function sendViaSmtpWithSettings(
       filename: a.filename,
       filename: a.filename,
       content: a.content,
       content: a.content,
     })),
     })),
+    ...nodemailerThreading(options),
   })
   })
 }
 }
 
 
@@ -259,6 +292,7 @@ async function sendViaResendWithSettings(
   }
   }
 
 
   const resend = new Resend(apiKey)
   const resend = new Resend(apiKey)
+  const headers = threadHeaders(options)
   await resend.emails.send({
   await resend.emails.send({
     from: options.from,
     from: options.from,
     to: options.to,
     to: options.to,
@@ -270,6 +304,7 @@ async function sendViaResendWithSettings(
       filename: a.filename,
       filename: a.filename,
       content: a.content,
       content: a.content,
     })),
     })),
+    ...(Object.keys(headers).length > 0 && { headers }),
   })
   })
 }
 }
 
 
@@ -291,6 +326,8 @@ async function sendViaPostmarkWithSettings(
   }
   }
 
 
   const client = new PostmarkClient(apiKey)
   const client = new PostmarkClient(apiKey)
+  const headers = Object.entries(threadHeaders(options)).map(([Name, Value]) => ({ Name, Value }))
+  const Headers = headers.length > 0 ? headers : undefined
 
 
   if (options.attachments?.length) {
   if (options.attachments?.length) {
     await client.sendEmail({
     await client.sendEmail({
@@ -300,6 +337,7 @@ async function sendViaPostmarkWithSettings(
       Subject: options.subject,
       Subject: options.subject,
       HtmlBody: options.html,
       HtmlBody: options.html,
       TextBody: options.text,
       TextBody: options.text,
+      Headers,
       Attachments: options.attachments.map((a) => ({
       Attachments: options.attachments.map((a) => ({
         Name: a.filename,
         Name: a.filename,
         Content: a.content.toString('base64'),
         Content: a.content.toString('base64'),
@@ -315,6 +353,7 @@ async function sendViaPostmarkWithSettings(
       Subject: options.subject,
       Subject: options.subject,
       HtmlBody: options.html,
       HtmlBody: options.html,
       TextBody: options.text,
       TextBody: options.text,
+      Headers,
     })
     })
   }
   }
 }
 }
@@ -346,10 +385,14 @@ async function sendViaMailgunWithSettings(
     url: region === 'eu' ? 'https://api.eu.mailgun.net' : undefined,
     url: region === 'eu' ? 'https://api.eu.mailgun.net' : undefined,
   })
   })
 
 
+  const headers = Object.fromEntries(
+    Object.entries(threadHeaders(options)).map(([name, value]) => [`h:${name}`, value])
+  )
   await mg.messages.create(domain, {
   await mg.messages.create(domain, {
     from: options.from,
     from: options.from,
     to: [options.to],
     to: [options.to],
     ...(options.replyTo && { 'h:Reply-To': options.replyTo }),
     ...(options.replyTo && { 'h:Reply-To': options.replyTo }),
+    ...headers,
     subject: options.subject,
     subject: options.subject,
     html: options.html,
     html: options.html,
     ...(options.text && { text: options.text }),
     ...(options.text && { text: options.text }),
@@ -390,6 +433,7 @@ async function sendViaSendGridWithSettings(
 
 
   sgMail.setApiKey(apiKey)
   sgMail.setApiKey(apiKey)
 
 
+  const headers = threadHeaders(options)
   const msg: MailDataRequired = {
   const msg: MailDataRequired = {
     from: options.from,
     from: options.from,
     to: options.to,
     to: options.to,
@@ -397,6 +441,7 @@ async function sendViaSendGridWithSettings(
     subject: options.subject,
     subject: options.subject,
     html: options.html,
     html: options.html,
     ...(options.text && { text: options.text }),
     ...(options.text && { text: options.text }),
+    ...(Object.keys(headers).length > 0 && { headers }),
   }
   }
 
 
   if (options.attachments?.length) {
   if (options.attachments?.length) {
@@ -443,6 +488,7 @@ async function sendViaSesWithSettings(
       filename: a.filename,
       filename: a.filename,
       content: a.content,
       content: a.content,
     })),
     })),
+    ...nodemailerThreading(options),
   })
   })
 
 
   const rawMessage = Buffer.isBuffer(info.message)
   const rawMessage = Buffer.isBuffer(info.message)
@@ -584,6 +630,28 @@ export async function getOrgFromAddress(organizationId: string): Promise<string>
   return orgFromAddressFor(provider, settings)
   return orgFromAddressFor(provider, settings)
 }
 }
 
 
+/**
+ * Whether this workshop's customer mail leaves from the platform's address:
+ * it connected no mail vendor of its own, and named none. The reply address
+ * and the inbox email channel exist only for these workshops; a vendor of
+ * the workshop's own sends from its own address, and replies go there.
+ */
+export async function usesPlatformMail(organizationId: string): Promise<boolean> {
+  return (await channelProvider(organizationId, 'email')) === null
+}
+
+/**
+ * The platform's sending address under another name: the workshop's own,
+ * on a mail sent for it. The address stays the platform's, since that is
+ * the domain the mail is signed for; only the name a customer sees changes.
+ */
+export async function platformFromAddressAs(displayName: string): Promise<string> {
+  const platform = await getFromAddress()
+  const parsed = parseMailAddress(platform)
+  if (!parsed) return platform
+  return formatMailAddress(displayName, parsed.email) || platform
+}
+
 /**
 /**
  * Send through one specific email connection rather than whichever the
  * Send through one specific email connection rather than whichever the
  * organization is pointed at. The connection page's "send a test email" runs
  * organization is pointed at. The connection page's "send a test email" runs

+ 1 - 0
src/lib/files/references.ts

@@ -84,6 +84,7 @@ export const FILE_REFERENCES: FileReference[] = [
   { model: 'ConditionMark', field: 'imageUrls', sql: array('condition_marks', 'imageUrls') },
   { model: 'ConditionMark', field: 'imageUrls', sql: array('condition_marks', 'imageUrls') },
   { model: 'StatusReport', field: 'videoUrl', sql: text('status_reports', 'videoUrl') },
   { model: 'StatusReport', field: 'videoUrl', sql: text('status_reports', 'videoUrl') },
   { model: 'WhatsappMessage', field: 'mediaUrl', sql: text('whatsapp_messages', 'mediaUrl') },
   { model: 'WhatsappMessage', field: 'mediaUrl', sql: text('whatsapp_messages', 'mediaUrl') },
+  { model: 'EmailAttachment', field: 'url', sql: text('email_attachments', 'url') },
   // Workshop logo, invoice and quote logos, the portal background. Every
   // Workshop logo, invoice and quote logos, the portal background. Every
   // setting is searched, so a new file-valued setting is covered too.
   // setting is searched, so a new file-valued setting is covered too.
   { model: 'AppSetting', field: 'value', sql: anywhere('app_settings', 'value') },
   { model: 'AppSetting', field: 'value', sql: anywhere('app_settings', 'value') },

+ 46 - 0
src/lib/reply-mail.server.ts

@@ -0,0 +1,46 @@
+import 'server-only'
+
+import { db } from './db'
+import { isReplyMailToken, newReplyMailToken } from './reply-mail'
+
+/**
+ * The workshop's reply token, minted on first use.
+ *
+ * Minting is an update of the null column only, so two sends racing for the
+ * first token cannot each write their own: one wins, the other reads the
+ * winner's. The token never changes afterwards, since a reply to an invoice
+ * sent months ago still has to find the workshop.
+ */
+export async function ensureReplyMailToken(organizationId: string): Promise<string> {
+  const current = await db.organization.findUnique({
+    where: { id: organizationId },
+    select: { replyMailToken: true },
+  })
+  if (!current) throw new Error('Organization not found')
+  if (isReplyMailToken(current.replyMailToken)) return current.replyMailToken
+
+  for (let attempt = 0; attempt < 3; attempt++) {
+    const token = newReplyMailToken()
+    const claimed = await db.organization.updateMany({
+      where: { id: organizationId, replyMailToken: null },
+      data: { replyMailToken: token },
+    })
+    if (claimed.count === 1) return token
+
+    const again = await db.organization.findUnique({
+      where: { id: organizationId },
+      select: { replyMailToken: true },
+    })
+    if (isReplyMailToken(again?.replyMailToken)) return again.replyMailToken
+  }
+  throw new Error('Could not mint a reply token')
+}
+
+/** The workshop a reply token belongs to, or null for a token nobody holds. */
+export async function organizationForReplyToken(token: string) {
+  if (!isReplyMailToken(token)) return null
+  return db.organization.findUnique({
+    where: { replyMailToken: token },
+    select: { id: true, name: true },
+  })
+}

+ 170 - 0
src/lib/reply-mail.ts

@@ -0,0 +1,170 @@
+import { randomBytes } from 'node:crypto'
+import { isCloudInstance } from './cloud-instance'
+
+/**
+ * The reply address customer mail carries on the cloud instance.
+ *
+ * A workshop that sends through the platform's address gets its replies back
+ * through `reply-<token>@<reply domain>`: the domain's mail is received by
+ * the platform and filed into that workshop's inbox. The token is the only
+ * thing that says which workshop a mail is for, so it is random and private
+ * to the workshop, never its id.
+ *
+ * None of this exists on a self-hosted install. The reply domain is set only
+ * on the cloud deployment, and even there it counts only once the cloud
+ * token has been verified, so a copied environment file enables nothing.
+ */
+
+const TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567'
+/** 128 bits of randomness, as 26 base32 characters: lower case, no padding. */
+export const REPLY_TOKEN_LENGTH = 26
+
+/** `reply-<token>`, with an optional `+tag` a mail client may append. */
+const LOCAL_PART = /^reply-([a-z2-7]{26})(?:\+[^@]*)?$/
+
+export interface InboundMailConfig {
+  /** The domain whose mail is received, e.g. `reply.torqvoice.com`. */
+  domain: string
+  /** The bucket the receipt rule writes mail into. */
+  bucket: string
+  /** The region of the receiving endpoint, the bucket and the topic. */
+  region: string
+  /** The topic whose notifications the webhook accepts, and no other. */
+  topicArn: string
+}
+
+function env(name: string): string | undefined {
+  const value = process.env[name]?.trim()
+  return value ? value : undefined
+}
+
+/** `INBOUND_MAIL_DOMAIN` as set, on the cloud instance only. */
+function configuredDomain(): string | null {
+  if (!isCloudInstance()) return null
+  const domain = env('INBOUND_MAIL_DOMAIN')?.toLowerCase()
+  return domain && /^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain) ? domain : null
+}
+
+/**
+ * The reply domain, when customer mail should carry a reply address at all:
+ * the cloud instance with the whole receiving setup in place. A domain on
+ * its own would put a reply address on every mail while the webhook, which
+ * refuses half a setup, could file nothing that came back to it.
+ */
+export function replyMailDomain(): string | null {
+  const result = inboundMailConfig()
+  return result.ok ? result.config.domain : null
+}
+
+export function replyMailEnabled(): boolean {
+  return replyMailDomain() !== null
+}
+
+/**
+ * Everything the receiving webhook needs, or null with what is missing. The
+ * webhook refuses to run on half a setup rather than guessing at the rest.
+ */
+export function inboundMailConfig():
+  | { ok: true; config: InboundMailConfig }
+  | { ok: false; missing: string[] } {
+  const domain = configuredDomain()
+  const bucket = env('INBOUND_MAIL_BUCKET')
+  const region = env('INBOUND_MAIL_REGION')
+  const topicArn = env('INBOUND_MAIL_TOPIC_ARN')
+
+  const missing: string[] = []
+  if (!domain) missing.push('INBOUND_MAIL_DOMAIN')
+  if (!bucket) missing.push('INBOUND_MAIL_BUCKET')
+  if (!region || !/^[a-z]{2}(-[a-z]+)+-\d$/.test(region)) missing.push('INBOUND_MAIL_REGION')
+  if (!topicArn || !topicArn.startsWith('arn:aws:sns:')) missing.push('INBOUND_MAIL_TOPIC_ARN')
+
+  if (missing.length > 0 || !domain || !bucket || !region || !topicArn) {
+    return { ok: false, missing }
+  }
+  return { ok: true, config: { domain, bucket, region, topicArn } }
+}
+
+/** A fresh reply token: 26 lower-case base32 characters from 16 random bytes. */
+export function newReplyMailToken(): string {
+  const bytes = randomBytes(16)
+  let bits = 0
+  let value = 0
+  let out = ''
+  for (const byte of bytes) {
+    value = (value << 8) | byte
+    bits += 8
+    while (bits >= 5) {
+      out += TOKEN_ALPHABET[(value >>> (bits - 5)) & 31]
+      bits -= 5
+    }
+  }
+  if (bits > 0) out += TOKEN_ALPHABET[(value << (5 - bits)) & 31]
+  return out.slice(0, REPLY_TOKEN_LENGTH)
+}
+
+export function isReplyMailToken(value: unknown): value is string {
+  return typeof value === 'string' && /^[a-z2-7]{26}$/.test(value)
+}
+
+/** The address a workshop's customers answer to. */
+export function replyMailAddress(token: string, domain: string): string {
+  return `reply-${token}@${domain}`
+}
+
+/**
+ * The token in a recipient address of ours, or null for any other address.
+ * Case does not matter in either half; a `+tag` after the token is allowed
+ * and ignored, since some clients add one when replying.
+ */
+export function parseReplyMailAddress(address: string, domain: string): string | null {
+  const at = address.lastIndexOf('@')
+  if (at <= 0) return null
+  const local = address.slice(0, at).trim().toLowerCase()
+  const host = address
+    .slice(at + 1)
+    .trim()
+    .toLowerCase()
+  if (host !== domain.toLowerCase()) return null
+  const match = LOCAL_PART.exec(local)
+  return match ? match[1] : null
+}
+
+/**
+ * The first of a mail's recipients that is one of ours. SES lists every
+ * envelope recipient on the receiving domain; a mail sent to two workshops at
+ * once is filed with the first, which is not something a customer does.
+ */
+export function replyTokenFromRecipients(recipients: readonly string[], domain: string) {
+  for (const recipient of recipients) {
+    const token = parseReplyMailAddress(recipient, domain)
+    if (token) return token
+  }
+  return null
+}
+
+/**
+ * A Message-ID for a mail we send, on the reply domain so a reply's
+ * In-Reply-To names something of ours. Returned without angle brackets, as
+ * it is stored; the mailer adds them on the wire.
+ */
+export function newMailMessageId(domain: string): string {
+  return `${newReplyMailToken()}.${Date.now().toString(36)}@${domain}`
+}
+
+/** A Message-ID header value as stored: trimmed, without its angle brackets. */
+export function normalizeMessageId(value: string | null | undefined): string | null {
+  if (!value) return null
+  const trimmed = value.trim().replace(/^<|>$/g, '').trim()
+  return trimmed && trimmed.length <= 998 ? trimmed : null
+}
+
+/** Every id in a References header, oldest first, as stored. */
+export function parseReferences(value: string | string[] | null | undefined): string[] {
+  const raw = Array.isArray(value) ? value.join(' ') : (value ?? '')
+  const ids: string[] = []
+  for (const match of raw.matchAll(/<([^<>\s]+)>/g)) {
+    const id = normalizeMessageId(match[1])
+    if (id && !ids.includes(id)) ids.push(id)
+  }
+  return ids
+}

+ 146 - 0
src/lib/sns-signature.ts

@@ -0,0 +1,146 @@
+import { createVerify } from 'node:crypto'
+
+/**
+ * Proof that an HTTP delivery came from Amazon SNS.
+ *
+ * SNS signs every delivery with a key whose certificate it publishes at a URL
+ * named in the message. The check is three parts, all here with no I/O: the
+ * certificate URL must be SNS's own (anyone can host a certificate), the
+ * canonical string is rebuilt from the message's fields in the order SNS
+ * signs them, and the signature is verified against the certificate. The
+ * caller fetches the certificate, since that is the only network step.
+ */
+
+export interface SnsEnvelope {
+  Type: string
+  MessageId: string
+  TopicArn: string
+  Message: string
+  Timestamp: string
+  SignatureVersion: string
+  Signature: string
+  SigningCertURL: string
+  Subject?: string
+  Token?: string
+  SubscribeURL?: string
+  UnsubscribeURL?: string
+}
+
+const STRING = (value: unknown): value is string => typeof value === 'string' && value.length > 0
+
+/** The envelope, when the body is one; null for anything else. */
+export function parseSnsEnvelope(body: unknown): SnsEnvelope | null {
+  if (!body || typeof body !== 'object') return null
+  const raw = body as Record<string, unknown>
+  const required = [
+    'Type',
+    'MessageId',
+    'TopicArn',
+    'Timestamp',
+    'SignatureVersion',
+    'Signature',
+    'SigningCertURL',
+  ] as const
+  for (const key of required) if (!STRING(raw[key])) return null
+  if (typeof raw.Message !== 'string') return null
+  for (const key of ['Subject', 'Token', 'SubscribeURL', 'UnsubscribeURL'] as const) {
+    if (raw[key] !== undefined && typeof raw[key] !== 'string') return null
+  }
+  return raw as unknown as SnsEnvelope
+}
+
+/**
+ * Whether the certificate URL is one SNS would publish: HTTPS, on the SNS
+ * host of the given region, naming a `.pem` file. Pinned to the region the
+ * topic lives in rather than any AWS region, so a certificate from a topic
+ * elsewhere does not pass either.
+ */
+export function isTrustedSigningCertUrl(url: string, region: string): boolean {
+  let parsed: URL
+  try {
+    parsed = new URL(url)
+  } catch {
+    return false
+  }
+  if (parsed.protocol !== 'https:') return false
+  if (parsed.username || parsed.password || parsed.port) return false
+  const host = parsed.hostname.toLowerCase()
+  if (host !== `sns.${region}.amazonaws.com`) return false
+  return /^\/[A-Za-z0-9._-]+\.pem$/.test(parsed.pathname)
+}
+
+/**
+ * The string SNS signed, rebuilt from the envelope. Notifications sign
+ * Message, MessageId, Subject (when present), Timestamp, TopicArn and Type;
+ * subscription messages sign Message, MessageId, SubscribeURL, Timestamp,
+ * Token, TopicArn and Type. Each field is its name, a newline, its value and
+ * a newline, in that order.
+ */
+export function snsStringToSign(envelope: SnsEnvelope): string | null {
+  const fields: Array<[string, string | undefined]> =
+    envelope.Type === 'Notification'
+      ? [
+          ['Message', envelope.Message],
+          ['MessageId', envelope.MessageId],
+          ...(envelope.Subject !== undefined
+            ? ([['Subject', envelope.Subject]] as Array<[string, string]>)
+            : []),
+          ['Timestamp', envelope.Timestamp],
+          ['TopicArn', envelope.TopicArn],
+          ['Type', envelope.Type],
+        ]
+      : envelope.Type === 'SubscriptionConfirmation' || envelope.Type === 'UnsubscribeConfirmation'
+        ? [
+            ['Message', envelope.Message],
+            ['MessageId', envelope.MessageId],
+            ['SubscribeURL', envelope.SubscribeURL],
+            ['Timestamp', envelope.Timestamp],
+            ['Token', envelope.Token],
+            ['TopicArn', envelope.TopicArn],
+            ['Type', envelope.Type],
+          ]
+        : []
+  if (fields.length === 0) return null
+
+  let out = ''
+  for (const [name, value] of fields) {
+    if (value === undefined) return null
+    out += `${name}\n${value}\n`
+  }
+  return out
+}
+
+/**
+ * Whether the signature is the certificate's over the canonical string.
+ * Signature version 1 is SHA1 with RSA, version 2 SHA256 with RSA; anything
+ * else is refused rather than guessed at.
+ */
+export function verifySnsSignature(envelope: SnsEnvelope, certificatePem: string): boolean {
+  const algorithm =
+    envelope.SignatureVersion === '1'
+      ? 'RSA-SHA1'
+      : envelope.SignatureVersion === '2'
+        ? 'RSA-SHA256'
+        : null
+  if (!algorithm) return false
+
+  const signed = snsStringToSign(envelope)
+  if (!signed) return false
+
+  let signature: Buffer
+  try {
+    signature = Buffer.from(envelope.Signature, 'base64')
+  } catch {
+    return false
+  }
+  if (signature.length === 0) return false
+
+  try {
+    const verifier = createVerify(algorithm)
+    verifier.update(signed, 'utf8')
+    verifier.end()
+    return verifier.verify(certificatePem, signature)
+  } catch {
+    return false
+  }
+}

+ 1 - 0
src/lib/upload-url.ts

@@ -20,6 +20,7 @@ export const UPLOAD_CATEGORIES = [
   'quotes',
   'quotes',
   'portal',
   'portal',
   'tire-hotel',
   'tire-hotel',
+  'email',
 ] as const
 ] as const
 
 
 export type UploadCategory = (typeof UPLOAD_CATEGORIES)[number]
 export type UploadCategory = (typeof UPLOAD_CATEGORIES)[number]

Некоторые файлы не были показаны из-за большого количества измененных файлов