Explorar el Código

Move messaging channels into Integrations with zero-touch adoption (#304)

* Move the messaging channels into Integrations

Email, SMS, WhatsApp and Telegram each had their own settings page writing
their own AppSetting rows, which left the catalog claiming to be where a
workshop connects things while four of the connections lived somewhere else.
They are connectors now: twelve of them, one per vendor, the way the catalog
already listed calendars and video calls.

Nothing is asked of a workshop that already had a channel set up. The first
time a channel is used, an existing setup is read out of its old rows, sealed
into a connection and used from then on, so Twilio configured two years ago
keeps sending without anyone opening a form. Webhook secrets come across
untouched, which matters because the vendor is already pointing at a URL built
from one. Vonage identifies a workshop by the secret in that URL and sealed
credentials cannot be searched, so the connection carries a hash of it to look
up by. scripts/adopt-messaging-integrations.ts does the same thing in bulk so
the catalog looks right from the first page load rather than the first send.

The old rows are left exactly where they are. Nothing reads them once a
connection exists, but a rollback still finds them.

The providers page is a signpost now, since forms that write to rows nobody
reads are worse than no forms. It says where each channel went and what it is
connected to. Email reads as on by default there: an organization without a
mail vendor of its own still sends through the platform's.

Logos are neutral placeholders rather than vendor artwork, and the eleven
non-English locales carry the English strings until they are translated.

* integrations send mail

* sms
Bernt Christian Egeland hace 4 semanas
padre
commit
e8b5608d9b
Se han modificado 100 ficheros con 5439 adiciones y 3476 borrados
  1. 173 1
      messages/de/integrations.json
  2. 11 0
      messages/de/settings.json
  3. 173 1
      messages/en/integrations.json
  4. 11 0
      messages/en/settings.json
  5. 173 1
      messages/es/integrations.json
  6. 11 0
      messages/es/settings.json
  7. 173 1
      messages/fr/integrations.json
  8. 11 0
      messages/fr/settings.json
  9. 173 1
      messages/it/integrations.json
  10. 11 0
      messages/it/settings.json
  11. 173 1
      messages/lt/integrations.json
  12. 11 0
      messages/lt/settings.json
  13. 173 1
      messages/nb/integrations.json
  14. 11 0
      messages/nb/settings.json
  15. 173 1
      messages/nl/integrations.json
  16. 11 0
      messages/nl/settings.json
  17. 173 1
      messages/pl/integrations.json
  18. 11 0
      messages/pl/settings.json
  19. 173 1
      messages/pt-BR/integrations.json
  20. 11 0
      messages/pt-BR/settings.json
  21. 173 1
      messages/ru/integrations.json
  22. 11 0
      messages/ru/settings.json
  23. 173 1
      messages/tr/integrations.json
  24. 11 0
      messages/tr/settings.json
  25. 6 0
      public/images/integrations/amazon-ses.svg
  26. 6 0
      public/images/integrations/mailgun.svg
  27. 6 0
      public/images/integrations/postmark.svg
  28. 6 0
      public/images/integrations/resend.svg
  29. 6 0
      public/images/integrations/sendgrid.svg
  30. 6 0
      public/images/integrations/smtp.svg
  31. 6 0
      public/images/integrations/telegram.svg
  32. 6 0
      public/images/integrations/telnyx-sms.svg
  33. 6 0
      public/images/integrations/twilio-sms.svg
  34. 6 0
      public/images/integrations/vonage-sms.svg
  35. 6 0
      public/images/integrations/whatsapp-meta.svg
  36. 6 0
      public/images/integrations/whatsapp-twilio.svg
  37. 142 0
      scripts/adopt-messaging-integrations.ts
  38. 121 0
      scripts/rekey-integrations.ts
  39. 447 0
      src/__tests__/features/integrations/messaging.test.ts
  40. 28 0
      src/__tests__/features/sms/sms-send.test.ts
  41. 6 24
      src/app/(authenticated)/customers/[id]/page.tsx
  42. 3 2
      src/app/(authenticated)/settings/email/page.tsx
  43. 252 89
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  44. 3 8
      src/app/(authenticated)/settings/invoice/page.tsx
  45. 70 99
      src/app/(authenticated)/settings/providers/page.tsx
  46. 0 70
      src/app/(authenticated)/settings/providers/provider-tabs.tsx
  47. 3 2
      src/app/(authenticated)/settings/sms/page.tsx
  48. 3 2
      src/app/(authenticated)/settings/telegram/page.tsx
  49. 3 2
      src/app/(authenticated)/settings/whatsapp/page.tsx
  50. 4 1
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  51. 2 1
      src/app/api/protected/services/[id]/pdf/route.ts
  52. 9 10
      src/app/api/webhooks/sms/telnyx/route.ts
  53. 9 11
      src/app/api/webhooks/sms/twilio/route.ts
  54. 7 10
      src/app/api/webhooks/sms/vonage/route.ts
  55. 13 11
      src/app/api/webhooks/telegram/[organizationId]/route.ts
  56. 0 100
      src/features/email/Actions/emailSettingsActions.ts
  57. 0 713
      src/features/email/Components/EmailSettingsForm.tsx
  58. 206 36
      src/features/integrations/Actions/integrationActions.ts
  59. 609 0
      src/features/integrations/Lib/messaging.ts
  60. 14 0
      src/features/integrations/Lib/types.ts
  61. 0 95
      src/features/sms/Actions/smsSettingsActions.ts
  62. 0 340
      src/features/sms/Components/SmsSettingsForm.tsx
  63. 0 165
      src/features/telegram/Actions/telegramSettingsActions.ts
  64. 0 66
      src/features/telegram/Components/TelegramDisconnectButton.tsx
  65. 0 197
      src/features/telegram/Components/TelegramSettingsForm.tsx
  66. 0 75
      src/features/telegram/Components/TelegramTestMessage.tsx
  67. 0 415
      src/features/whatsapp/Actions/whatsappSettingsActions.ts
  68. 0 164
      src/features/whatsapp/Components/TemplateSetupFields.tsx
  69. 0 682
      src/features/whatsapp/Components/WhatsappSettingsForm.tsx
  70. 3 0
      src/integrations/amazon-ses/manifest.ts
  71. 27 0
      src/integrations/amazon-ses/server.ts
  72. 3 0
      src/integrations/mailgun/manifest.ts
  73. 41 0
      src/integrations/mailgun/server.ts
  74. 469 0
      src/integrations/messaging/catalog.ts
  75. 34 0
      src/integrations/messaging/email-test.ts
  76. 156 0
      src/integrations/messaging/factory.ts
  77. 3 0
      src/integrations/postmark/manifest.ts
  78. 27 0
      src/integrations/postmark/server.ts
  79. 24 0
      src/integrations/registry.ts
  80. 3 0
      src/integrations/resend/manifest.ts
  81. 30 0
      src/integrations/resend/server.ts
  82. 3 0
      src/integrations/sendgrid/manifest.ts
  83. 35 0
      src/integrations/sendgrid/server.ts
  84. 3 0
      src/integrations/smtp/manifest.ts
  85. 30 0
      src/integrations/smtp/server.ts
  86. 3 0
      src/integrations/telegram/manifest.ts
  87. 70 0
      src/integrations/telegram/server.ts
  88. 3 0
      src/integrations/telnyx-sms/manifest.ts
  89. 12 0
      src/integrations/telnyx-sms/server.ts
  90. 3 0
      src/integrations/twilio-sms/manifest.ts
  91. 30 0
      src/integrations/twilio-sms/server.ts
  92. 3 0
      src/integrations/vonage-sms/manifest.ts
  93. 13 0
      src/integrations/vonage-sms/server.ts
  94. 3 0
      src/integrations/whatsapp-meta/manifest.ts
  95. 28 0
      src/integrations/whatsapp-meta/server.ts
  96. 3 0
      src/integrations/whatsapp-twilio/manifest.ts
  97. 24 0
      src/integrations/whatsapp-twilio/server.ts
  98. 64 30
      src/lib/email.ts
  99. 32 34
      src/lib/sms.ts
  100. 23 10
      src/lib/telegram.ts

+ 173 - 1
messages/de/integrations.json

@@ -30,6 +30,21 @@
       "push": "Aufträge in den Kalender",
       "pull": "Belegte Zeiten aus dem Kalender",
       "conference": "Links für Videoanrufe"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Auswählen...",
     "planLocked": "Integrationen sind in Ihrem Tarif nicht enthalten.",
     "ownAppTitle": "Diese Installation verwendet Ihre eigene App beim Anbieter",
-    "redirectUri": "Beim Anbieter zu registrierende Redirect-URI:"
+    "redirectUri": "Beim Anbieter zu registrierende Redirect-URI:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Aktivität",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Kunden vor Ihnen beitreten lassen",
         "waitingRoom": "Warteraum verwenden"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/de/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Telegram-Nachrichten",
         "description": "Telegram-Nachrichten mit Ihren Kunden direkt aus Torqvoice senden und empfangen."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/en/integrations.json

@@ -30,6 +30,21 @@
       "push": "Work orders to calendar",
       "pull": "Busy time from calendar",
       "conference": "Video call links"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Choose...",
     "planLocked": "Integrations are not included in your plan.",
     "ownAppTitle": "This install uses your own app with the provider",
-    "redirectUri": "Redirect URI to register with the provider:"
+    "redirectUri": "Redirect URI to register with the provider:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Activity",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Let the customer join before you",
         "waitingRoom": "Use a waiting room"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/en/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Telegram Messaging",
         "description": "Send and receive Telegram messages with your customers directly from Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/es/integrations.json

@@ -30,6 +30,21 @@
       "push": "Órdenes de trabajo al calendario",
       "pull": "Tiempo ocupado del calendario",
       "conference": "Enlaces de videollamada"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Elegir...",
     "planLocked": "Las integraciones no están incluidas en su plan.",
     "ownAppTitle": "Esta instalación usa su propia aplicación con el proveedor",
-    "redirectUri": "URI de redirección que debe registrar con el proveedor:"
+    "redirectUri": "URI de redirección que debe registrar con el proveedor:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Actividad",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Permitir que el cliente entre antes que usted",
         "waitingRoom": "Usar sala de espera"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/es/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Mensajería Telegram",
         "description": "Envía y recibe mensajes de Telegram con tus clientes desde Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/fr/integrations.json

@@ -30,6 +30,21 @@
       "push": "Ordres de travail vers le calendrier",
       "pull": "Plages occupées du calendrier",
       "conference": "Liens d'appel vidéo"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Choisir...",
     "planLocked": "Les intégrations ne sont pas incluses dans votre forfait.",
     "ownAppTitle": "Cette installation utilise votre propre application auprès du fournisseur",
-    "redirectUri": "URI de redirection à enregistrer auprès du fournisseur :"
+    "redirectUri": "URI de redirection à enregistrer auprès du fournisseur :",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Activité",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Laisser le client rejoindre avant vous",
         "waitingRoom": "Utiliser une salle d'attente"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/fr/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Messagerie Telegram",
         "description": "Envoyez et recevez des messages Telegram avec vos clients depuis Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/it/integrations.json

@@ -30,6 +30,21 @@
       "push": "Ordini di lavoro nel calendario",
       "pull": "Impegni dal calendario",
       "conference": "Link per videochiamate"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Scegli...",
     "planLocked": "Le integrazioni non sono incluse nel tuo piano.",
     "ownAppTitle": "Questa installazione usa la tua app presso il provider",
-    "redirectUri": "URI di reindirizzamento da registrare presso il provider:"
+    "redirectUri": "URI di reindirizzamento da registrare presso il provider:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Attività",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Consenti al cliente di entrare prima di te",
         "waitingRoom": "Usa la sala d'attesa"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/it/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Messaggi Telegram",
         "description": "Invia e ricevi messaggi Telegram con i tuoi clienti da Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/lt/integrations.json

@@ -30,6 +30,21 @@
       "push": "Darbo užsakymai į kalendorių",
       "pull": "Užimtas laikas iš kalendoriaus",
       "conference": "Vaizdo skambučių nuorodos"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Pasirinkite...",
     "planLocked": "Integracijos neįtrauktos į jūsų planą.",
     "ownAppTitle": "Šis diegimas naudoja jūsų pačių programą pas tiekėją",
-    "redirectUri": "Nukreipimo URI, kurį reikia užregistruoti pas tiekėją:"
+    "redirectUri": "Nukreipimo URI, kurį reikia užregistruoti pas tiekėją:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Veikla",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Leisti klientui prisijungti anksčiau už jus",
         "waitingRoom": "Naudoti laukimo kambarį"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/lt/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "„Telegram“ žinutės",
         "description": "Siųskite ir gaukite „Telegram“ žinutes su klientais tiesiai iš „Torqvoice“."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/nb/integrations.json

@@ -30,6 +30,21 @@
       "push": "Arbeidsordre til kalender",
       "pull": "Opptatt tid fra kalender",
       "conference": "Lenker til videosamtaler"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Velg...",
     "planLocked": "Integrasjoner er ikke inkludert i abonnementet ditt.",
     "ownAppTitle": "Denne installasjonen bruker din egen app hos leverandøren",
-    "redirectUri": "Omdirigerings-URI som må registreres hos leverandøren:"
+    "redirectUri": "Omdirigerings-URI som må registreres hos leverandøren:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Aktivitet",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "La kunden bli med før deg",
         "waitingRoom": "Bruk venterom"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/nb/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Telegram-meldinger",
         "description": "Send og motta Telegram-meldinger med kundene dine rett fra Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/nl/integrations.json

@@ -30,6 +30,21 @@
       "push": "Werkorders naar agenda",
       "pull": "Bezette tijd uit agenda",
       "conference": "Links voor videogesprekken"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Kiezen...",
     "planLocked": "Integraties zijn niet inbegrepen in uw abonnement.",
     "ownAppTitle": "Deze installatie gebruikt uw eigen app bij de provider",
-    "redirectUri": "Redirect-URI om bij de provider te registreren:"
+    "redirectUri": "Redirect-URI om bij de provider te registreren:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Activiteit",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Klant vóór u laten deelnemen",
         "waitingRoom": "Wachtkamer gebruiken"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/nl/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Telegram-berichten",
         "description": "Verstuur en ontvang Telegram-berichten met je klanten vanuit Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/pl/integrations.json

@@ -30,6 +30,21 @@
       "push": "Zlecenia do kalendarza",
       "pull": "Zajęty czas z kalendarza",
       "conference": "Linki do rozmów wideo"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Wybierz...",
     "planLocked": "Integracje nie są objęte Twoim planem.",
     "ownAppTitle": "Ta instalacja używa własnej aplikacji u dostawcy",
-    "redirectUri": "Adres przekierowania (redirect URI) do zarejestrowania u dostawcy:"
+    "redirectUri": "Adres przekierowania (redirect URI) do zarejestrowania u dostawcy:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Aktywność",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Pozwól klientowi dołączyć przed Tobą",
         "waitingRoom": "Używaj poczekalni"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/pl/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Wiadomości Telegram",
         "description": "Wysyłaj i odbieraj wiadomości Telegram z klientami bezpośrednio w Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/pt-BR/integrations.json

@@ -30,6 +30,21 @@
       "push": "Ordens de serviço no calendário",
       "pull": "Horários ocupados do calendário",
       "conference": "Links de videochamada"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Escolher...",
     "planLocked": "As integrações não estão incluídas no seu plano.",
     "ownAppTitle": "Esta instalação usa seu próprio aplicativo junto ao provedor",
-    "redirectUri": "URI de redirecionamento para registrar no provedor:"
+    "redirectUri": "URI de redirecionamento para registrar no provedor:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Atividade",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Permitir que o cliente entre antes de você",
         "waitingRoom": "Usar sala de espera"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/pt-BR/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Mensagens Telegram",
         "description": "Envie e receba mensagens do Telegram com seus clientes direto do Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/ru/integrations.json

@@ -30,6 +30,21 @@
       "push": "Заказ-наряды в календарь",
       "pull": "Занятое время из календаря",
       "conference": "Ссылки на видеозвонки"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Выберите...",
     "planLocked": "Интеграции не входят в ваш тариф.",
     "ownAppTitle": "Эта установка использует ваше собственное приложение у провайдера",
-    "redirectUri": "URI перенаправления для регистрации у провайдера:"
+    "redirectUri": "URI перенаправления для регистрации у провайдера:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Активность",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Разрешить клиенту входить раньше вас",
         "waitingRoom": "Использовать зал ожидания"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/ru/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Сообщения Telegram",
         "description": "Отправляйте и получайте сообщения Telegram от клиентов прямо в Torqvoice."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 173 - 1
messages/tr/integrations.json

@@ -30,6 +30,21 @@
       "push": "İş emirlerini takvime aktarma",
       "pull": "Takvimden meşgul zamanlar",
       "conference": "Görüntülü görüşme bağlantıları"
+    },
+    "sms": {
+      "send": "Text messages out",
+      "receive": "Replies back in"
+    },
+    "whatsapp": {
+      "send": "WhatsApp messages out",
+      "receive": "Replies back in"
+    },
+    "telegram": {
+      "send": "Telegram messages out",
+      "receive": "Replies back in"
+    },
+    "email": {
+      "send": "Email out"
     }
   },
   "statuses": {
@@ -68,7 +83,17 @@
     "choose": "Seçin...",
     "planLocked": "Entegrasyonlar planınıza dahil değil.",
     "ownAppTitle": "Bu kurulum, sağlayıcıda kendi uygulamanızı kullanıyor",
-    "redirectUri": "Sağlayıcıya kaydedilecek yönlendirme URI'si:"
+    "redirectUri": "Sağlayıcıya kaydedilecek yönlendirme URI'si:",
+    "sendTestEmail": "Send test email",
+    "testEmailSent": "Test email sent to {email}",
+    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
+    "inboundUrl": "Inbound webhook URL",
+    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
+    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "copy": "Copy",
+    "copied": "Copied",
+    "updateKeys": "Update keys",
+    "updateKeysHint": "The last check failed. Enter the keys again to try once more."
   },
   "activity": {
     "title": "Aktivite",
@@ -155,6 +180,153 @@
         "joinBeforeHost": "Müşterinin sizden önce katılmasına izin ver",
         "waitingRoom": "Bekleme odası kullan"
       }
+    },
+    "twilio-sms": {
+      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "vonage-sms": {
+      "description": "Send and receive text messages through a Vonage number.",
+      "fields": {
+        "apiKey": "API key",
+        "apiSecret": "API secret"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "telnyx-sms": {
+      "description": "Send and receive text messages through a Telnyx number.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "phoneNumber": "Send from this number"
+      }
+    },
+    "whatsapp-meta": {
+      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "fields": {
+        "phoneNumberId": "Phone number ID",
+        "accessToken": "Access token",
+        "verifyToken": "Webhook verify token",
+        "appSecret": "App secret",
+        "apiVersion": "Graph API version"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "whatsapp-twilio": {
+      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "fields": {
+        "accountSid": "Account SID",
+        "authToken": "Auth token",
+        "messagingServiceSid": "Messaging service SID"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "phoneNumber": "Send from this number",
+        "templateName": "Template for reopening a conversation",
+        "templateLanguage": "Template language",
+        "templateVariables": "Template values, in order",
+        "mediaTemplateName": "Template for a message with a photo",
+        "mediaTemplateLanguage": "Photo template language",
+        "mediaTemplateVariables": "Photo template values, in order"
+      }
+    },
+    "telegram": {
+      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "fields": {
+        "botToken": "Bot token"
+      },
+      "settings": {
+        "enabled": "Send messages on this channel",
+        "botUsername": "Bot username"
+      }
+    },
+    "smtp": {
+      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "fields": {
+        "host": "Server",
+        "port": "Port",
+        "user": "Username",
+        "pass": "Password"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "secure": "Connect over TLS from the start",
+        "requireTls": "Require the server to upgrade to TLS",
+        "rejectUnauthorized": "Reject a certificate that does not check out"
+      }
+    },
+    "resend": {
+      "description": "Send invoices and reminders through Resend.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "postmark": {
+      "description": "Send invoices and reminders through Postmark.",
+      "fields": {
+        "apiKey": "Server token"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "mailgun": {
+      "description": "Send invoices and reminders through Mailgun.",
+      "fields": {
+        "apiKey": "API key",
+        "domain": "Sending domain"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender",
+        "region": "Region the domain is in"
+      }
+    },
+    "sendgrid": {
+      "description": "Send invoices and reminders through SendGrid.",
+      "fields": {
+        "apiKey": "API key"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
+    },
+    "amazon-ses": {
+      "description": "Send invoices and reminders through Amazon SES.",
+      "fields": {
+        "accessKeyId": "Access key ID",
+        "secretAccessKey": "Secret access key",
+        "region": "Region"
+      },
+      "settings": {
+        "fromEmail": "Send from this address",
+        "fromName": "Name shown as the sender"
+      }
     }
   }
 }

+ 11 - 0
messages/tr/settings.json

@@ -1753,6 +1753,17 @@
         "feature": "Telegram mesajlaşma",
         "description": "Müşterilerinizle Telegram mesajlarını doğrudan Torqvoice üzerinden gönderip alın."
       }
+    },
+    "moved": {
+      "title": "These settings moved to Integrations",
+      "description": "Email, SMS, WhatsApp and Telegram are set up in Integrations now, alongside calendars and everything else you connect. What you had set up came with them, so there is nothing to enter again.",
+      "connectedTo": "Connected through {vendor}",
+      "notSetUp": "Not set up yet",
+      "manage": "Manage",
+      "notConnected": "Not connected",
+      "goToIntegrations": "Go to Integrations",
+      "platformMail": "Sending through the platform mail server. Connect your own to send from your address.",
+      "onByDefault": "On by default"
     }
   },
   "designer": {

+ 6 - 0
public/images/integrations/amazon-ses.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="amazon ses">
+  <title>amazon ses</title>
+  <rect width="48" height="48" rx="11" fill="#d97706"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="15" font-weight="600" text-anchor="middle" dominant-baseline="central">SES</text>
+</svg>

+ 6 - 0
public/images/integrations/mailgun.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="mailgun">
+  <title>mailgun</title>
+  <rect width="48" height="48" rx="11" fill="#d97706"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Mg</text>
+</svg>

+ 6 - 0
public/images/integrations/postmark.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="postmark">
+  <title>postmark</title>
+  <rect width="48" height="48" rx="11" fill="#d97706"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Pm</text>
+</svg>

+ 6 - 0
public/images/integrations/resend.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="resend">
+  <title>resend</title>
+  <rect width="48" height="48" rx="11" fill="#d97706"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Re</text>
+</svg>

+ 6 - 0
public/images/integrations/sendgrid.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="sendgrid">
+  <title>sendgrid</title>
+  <rect width="48" height="48" rx="11" fill="#d97706"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Sg</text>
+</svg>

+ 6 - 0
public/images/integrations/smtp.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="smtp">
+  <title>smtp</title>
+  <rect width="48" height="48" rx="11" fill="#d97706"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">SM</text>
+</svg>

+ 6 - 0
public/images/integrations/telegram.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="telegram">
+  <title>telegram</title>
+  <rect width="48" height="48" rx="11" fill="#0ea5e9"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Tg</text>
+</svg>

+ 6 - 0
public/images/integrations/telnyx-sms.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="telnyx sms">
+  <title>telnyx sms</title>
+  <rect width="48" height="48" rx="11" fill="#2563eb"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Tx</text>
+</svg>

+ 6 - 0
public/images/integrations/twilio-sms.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="twilio sms">
+  <title>twilio sms</title>
+  <rect width="48" height="48" rx="11" fill="#2563eb"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Tw</text>
+</svg>

+ 6 - 0
public/images/integrations/vonage-sms.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="vonage sms">
+  <title>vonage sms</title>
+  <rect width="48" height="48" rx="11" fill="#2563eb"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Vo</text>
+</svg>

+ 6 - 0
public/images/integrations/whatsapp-meta.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="whatsapp meta">
+  <title>whatsapp meta</title>
+  <rect width="48" height="48" rx="11" fill="#16a34a"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">Wa</text>
+</svg>

+ 6 - 0
public/images/integrations/whatsapp-twilio.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="whatsapp twilio">
+  <title>whatsapp twilio</title>
+  <rect width="48" height="48" rx="11" fill="#16a34a"/>
+  <text x="24" y="24" fill="#ffffff" font-family="system-ui, -apple-system, Segoe UI, sans-serif"
+        font-size="18" font-weight="600" text-anchor="middle" dominant-baseline="central">WT</text>
+</svg>

+ 142 - 0
scripts/adopt-messaging-integrations.ts

@@ -0,0 +1,142 @@
+/**
+ * Moves every existing messaging setup into a connection, in one pass.
+ *
+ * The app adopts a setup on its own the first time a workshop sends on a
+ * channel, so this script is not required for correctness — it just means the
+ * catalog shows Twilio, WhatsApp, Telegram and mail as connected from the
+ * moment the deploy lands, rather than the first time each workshop sends
+ * something. Nothing is deleted: the old `AppSetting` rows stay exactly where
+ * they are, so this can be run before a rollback is ruled out.
+ *
+ * Running it twice is safe. A workshop that already has a connection for a
+ * channel, or that has already been adopted, is left alone.
+ *
+ * It must run with the same INTEGRATIONS_ENCRYPTION_KEY the app uses: a
+ * connection sealed under any other key is unreadable by the app, and the
+ * adoption marker then stops the app from adopting the rows again. The script
+ * therefore refuses to run on a derived key and prints a fingerprint of the
+ * key it holds, to compare with the app container's.
+ *
+ *   docker run --rm --network proxy --env-file /path/to/prod/.env \
+ *     -v $(pwd):/src -w /src node:22-alpine sh -c \
+ *     'npm ci --ignore-scripts && npx prisma generate && \
+ *      npx tsx scripts/adopt-messaging-integrations.ts'           # report only
+ *   ... npx tsx scripts/adopt-messaging-integrations.ts --write   # adopt
+ */
+
+import { createHash } from 'node:crypto'
+import { adoptedMarkerKey, channelSetup, legacySetupFor } from '@/features/integrations/Lib/messaging'
+import type { MessagingChannel } from '@/integrations/messaging/catalog'
+import { legacyKeysForChannel, providersForChannel } from '@/integrations/messaging/catalog'
+import { db } from '@/lib/db'
+
+const CHANNELS: MessagingChannel[] = ['sms', 'whatsapp', 'telegram', 'email']
+
+function requireVaultKey(): void {
+  const key = process.env.INTEGRATIONS_ENCRYPTION_KEY?.trim()
+  if (!key) {
+    console.error(
+      'INTEGRATIONS_ENCRYPTION_KEY is not set. Run this with the app\'s own environment ' +
+        '(for example --env-file with the production .env) so connections are sealed with ' +
+        'the key the app reads them with. Refusing to continue.'
+    )
+    process.exit(2)
+  }
+  const fingerprint = createHash('sha256').update(key).digest('hex').slice(0, 8)
+  console.log(
+    `Sealing with INTEGRATIONS_ENCRYPTION_KEY fingerprint ${fingerprint}. ` +
+      'Compare: docker exec torqvoice-app sh -c \'printf %s "$INTEGRATIONS_ENCRYPTION_KEY" | sha256sum | cut -c1-8\''
+  )
+}
+
+async function organizationsWithLegacySetup(channel: MessagingChannel): Promise<string[]> {
+  const rows = await db.appSetting.findMany({
+    where: { key: { in: legacyKeysForChannel(channel) }, NOT: { value: '' } },
+    select: { organizationId: true },
+    distinct: ['organizationId'],
+  })
+  return rows.map((r) => r.organizationId).filter((id): id is string => Boolean(id))
+}
+
+async function main(): Promise<void> {
+  const write = process.argv.includes('--write')
+  requireVaultKey()
+
+  let adopted = 0
+  let alreadyConnected = 0
+  let alreadyAdopted = 0
+  let incomplete = 0
+  let failed = 0
+
+  for (const channel of CHANNELS) {
+    const connectorIds = providersForChannel(channel).map((p) => p.id)
+    const organizationIds = await organizationsWithLegacySetup(channel)
+
+    for (const organizationId of organizationIds) {
+      const [existing, marker] = await Promise.all([
+        db.integrationConnection.findFirst({
+          where: { organizationId, connectorId: { in: connectorIds } },
+          select: { connectorId: true, status: true },
+        }),
+        db.appSetting.findUnique({
+          where: { organizationId_key: { organizationId, key: adoptedMarkerKey(channel) } },
+          select: { value: true },
+        }),
+      ])
+      if (existing) {
+        alreadyConnected++
+        continue
+      }
+      if (marker) {
+        // Adopted and later disconnected by the workshop: that is its choice.
+        alreadyAdopted++
+        continue
+      }
+
+      // The same read the app does, without writing, so the report says
+      // exactly what --write would do.
+      const { setup } = await legacySetupFor(organizationId, channel)
+      if (!setup) {
+        console.log(
+          `incomplete ${channel} for ${organizationId}: no vendor named, or its keys are missing`
+        )
+        incomplete++
+        continue
+      }
+
+      if (!write) {
+        console.log(`would adopt ${channel} -> ${setup.provider.id} for ${organizationId}`)
+        adopted++
+        continue
+      }
+
+      try {
+        const live = await channelSetup(organizationId, channel)
+        if (live) {
+          console.log(`adopted ${channel} -> ${live.connectorId} for ${organizationId}`)
+          adopted++
+        } else {
+          console.log(`skipped ${channel} for ${organizationId}: adoption returned nothing`)
+          incomplete++
+        }
+      } catch (err) {
+        console.error(`failed ${channel} for ${organizationId}:`, err)
+        failed++
+      }
+    }
+  }
+
+  console.log(
+    `\n${write ? 'Adopted' : 'Would adopt'} ${adopted}, already connected ${alreadyConnected}, ` +
+      `previously adopted ${alreadyAdopted}, incomplete ${incomplete}, failed ${failed}.`
+  )
+  if (!write) console.log('Run again with --write to make the changes.')
+  if (failed > 0) process.exitCode = 1
+}
+
+main()
+  .catch((err) => {
+    console.error(err)
+    process.exitCode = 1
+  })
+  .finally(() => db.$disconnect())

+ 121 - 0
scripts/rekey-integrations.ts

@@ -0,0 +1,121 @@
+/**
+ * Re-seals every integration connection under a new vault key.
+ *
+ * Credentials are sealed with INTEGRATIONS_ENCRYPTION_KEY, or with a key
+ * derived from BETTER_AUTH_SECRET when that is unset. Changing either without
+ * re-sealing leaves every connection unreadable: calendars stop syncing and
+ * the messaging channels fall back to the settings rows from before the move,
+ * logging an error on every send. This script is the way to change a key.
+ *
+ * Give it the key the rows are sealed with today and the key they should be
+ * sealed with from now on. Either may be given as the 64-hex value or as the
+ * BETTER_AUTH_SECRET it was derived from:
+ *
+ *   OLD_INTEGRATIONS_ENCRYPTION_KEY=<hex> NEW_INTEGRATIONS_ENCRYPTION_KEY=<hex> \
+ *     npx tsx scripts/rekey-integrations.ts            # report only
+ *   ... npx tsx scripts/rekey-integrations.ts --write  # re-seal
+ *
+ *   OLD_BETTER_AUTH_SECRET=<secret> NEW_INTEGRATIONS_ENCRYPTION_KEY=<hex> ...
+ *
+ * Run it while the app still holds the old key, then switch the app to the
+ * new key and restart. Rows that the old key cannot open are reported and
+ * left as they are.
+ */
+
+import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
+import { db } from '@/lib/db'
+
+const VERSION = 'v1'
+
+function keyFrom(prefix: 'OLD' | 'NEW'): Buffer {
+  const explicit = process.env[`${prefix}_INTEGRATIONS_ENCRYPTION_KEY`]?.trim()
+  if (explicit) {
+    if (!/^[0-9a-f]{64}$/i.test(explicit)) {
+      throw new Error(`${prefix}_INTEGRATIONS_ENCRYPTION_KEY must be 64 hex characters`)
+    }
+    return Buffer.from(explicit, 'hex')
+  }
+  const secret = process.env[`${prefix}_BETTER_AUTH_SECRET`]
+  if (!secret) {
+    throw new Error(
+      `Set ${prefix}_INTEGRATIONS_ENCRYPTION_KEY or ${prefix}_BETTER_AUTH_SECRET (the same derivation the app uses)`
+    )
+  }
+  return Buffer.from(hkdfSync('sha256', secret, 'torqvoice', 'integrations-vault', 32))
+}
+
+function open(sealed: string, key: Buffer): string {
+  const [version, ivB64, tagB64, dataB64] = sealed.split('.')
+  if (version !== VERSION || !ivB64 || !tagB64 || !dataB64) {
+    throw new Error('Unrecognised credential format')
+  }
+  const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64url'))
+  decipher.setAuthTag(Buffer.from(tagB64, 'base64url'))
+  return Buffer.concat([
+    decipher.update(Buffer.from(dataB64, 'base64url')),
+    decipher.final(),
+  ]).toString('utf8')
+}
+
+function seal(plaintext: string, key: Buffer): string {
+  const iv = randomBytes(12)
+  const cipher = createCipheriv('aes-256-gcm', key, iv)
+  const encrypted = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()])
+  return [
+    VERSION,
+    iv.toString('base64url'),
+    cipher.getAuthTag().toString('base64url'),
+    encrypted.toString('base64url'),
+  ].join('.')
+}
+
+async function main(): Promise<void> {
+  const write = process.argv.includes('--write')
+  const oldKey = keyFrom('OLD')
+  const newKey = keyFrom('NEW')
+  if (oldKey.equals(newKey)) {
+    console.log('Old and new keys are the same; nothing to do.')
+    return
+  }
+
+  const rows = await db.integrationConnection.findMany({
+    where: { credentials: { not: null } },
+    select: { id: true, connectorId: true, organizationId: true, credentials: true },
+  })
+
+  let resealed = 0
+  let unreadable = 0
+  for (const row of rows) {
+    if (!row.credentials) continue
+    let plaintext: string
+    try {
+      plaintext = open(row.credentials, oldKey)
+    } catch {
+      console.error(
+        `cannot open ${row.connectorId} connection ${row.id} of ${row.organizationId} with the old key; left as is`
+      )
+      unreadable++
+      continue
+    }
+    if (write) {
+      await db.integrationConnection.update({
+        where: { id: row.id },
+        data: { credentials: seal(plaintext, newKey) },
+      })
+    }
+    resealed++
+  }
+
+  console.log(
+    `${write ? 'Re-sealed' : 'Would re-seal'} ${resealed} of ${rows.length} connections; ${unreadable} unreadable with the old key.`
+  )
+  if (!write) console.log('Run again with --write to make the changes.')
+  if (unreadable > 0) process.exitCode = 1
+}
+
+main()
+  .catch((err) => {
+    console.error(err)
+    process.exitCode = 1
+  })
+  .finally(() => db.$disconnect())

+ 447 - 0
src/__tests__/features/integrations/messaging.test.ts

@@ -0,0 +1,447 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { listWhatsappAdapters } from '@/lib/whatsapp/registry'
+import {
+  MESSAGING_PROVIDERS,
+  legacyKeysForChannel,
+  messagingProvider,
+  providerForLegacyId,
+  providersForChannel,
+} from '@/integrations/messaging/catalog'
+import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
+import { whatsappCredentialKey } from '@/features/whatsapp/Schema/whatsappSettingsSchema'
+
+const appSetting = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() }
+const integrationConnection = {
+  findMany: vi.fn(),
+  findFirst: vi.fn(),
+  findUnique: vi.fn(),
+  create: vi.fn(),
+  upsert: vi.fn(),
+  update: vi.fn(),
+  updateMany: vi.fn(),
+}
+
+vi.mock('@/lib/db', () => ({ db: { appSetting, integrationConnection } }))
+
+const {
+  adoptedMarkerKey,
+  asLegacyMap,
+  channelEnabled,
+  channelSetup,
+  completeMessagingCredentials,
+  legacyProviderNamed,
+  organizationForWebhookSecret,
+  webhookSecretHash,
+} = await import('@/features/integrations/Lib/messaging')
+
+function legacyRows(values: Record<string, string>) {
+  return Object.entries(values).map(([key, value]) => ({ key, value, userId: 'user-1' }))
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  process.env.INTEGRATIONS_ENCRYPTION_KEY = 'a'.repeat(64)
+  integrationConnection.findMany.mockResolvedValue([])
+  integrationConnection.findUnique.mockResolvedValue(null)
+  integrationConnection.findFirst.mockResolvedValue(null)
+  appSetting.findUnique.mockResolvedValue(null)
+})
+
+/**
+ * The move is only safe if a workshop that set a channel up years ago keeps
+ * sending without touching anything, so that is what these check.
+ */
+describe('messaging catalog', () => {
+  it('gives every vendor a unique id and a channel', () => {
+    const ids = MESSAGING_PROVIDERS.map((p) => p.id)
+    expect(new Set(ids).size).toBe(ids.length)
+    expect(providersForChannel('sms').map((p) => p.id)).toEqual([
+      'twilio-sms',
+      'vonage-sms',
+      'telnyx-sms',
+    ])
+  })
+
+  it('maps every old provider value onto a connector', () => {
+    expect(providerForLegacyId('sms', 'twilio')?.id).toBe('twilio-sms')
+    expect(providerForLegacyId('email', 'ses')?.id).toBe('amazon-ses')
+    // Telegram never had a provider row; the one connector answers regardless.
+    expect(providerForLegacyId('telegram', null)?.id).toBe('telegram')
+    expect(providerForLegacyId('sms', 'nonsense')).toBeNull()
+  })
+
+  it('declares the same WhatsApp credentials the adapters do', () => {
+    for (const adapter of listWhatsappAdapters()) {
+      const connector = MESSAGING_PROVIDERS.find(
+        (p) => p.channel === 'whatsapp' && p.legacyProvider === adapter.id
+      )
+      expect(connector, `no connector for WhatsApp adapter ${adapter.id}`).toBeTruthy()
+      for (const field of adapter.credentials) {
+        const declared = connector?.credentials.find((c) => c.key === field.key)
+        expect(declared, `${adapter.id} is missing ${field.key}`).toBeTruthy()
+        expect(declared?.legacy).toBe(whatsappCredentialKey(adapter.id, field.key))
+      }
+    }
+  })
+
+  it('lists the old keys a channel could have used', () => {
+    const keys = legacyKeysForChannel('sms')
+    expect(keys).toContain(ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID)
+    expect(keys).toContain(ORG_SMS_KEYS.SMS_PHONE_NUMBER)
+    // One entry, even though all three vendors point at it.
+    expect(keys.filter((k) => k === ORG_SMS_KEYS.SMS_PHONE_NUMBER)).toHaveLength(1)
+  })
+})
+
+describe('adopting an existing setup', () => {
+  it('turns old Twilio settings into a connection without asking anything', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: 'secret-token',
+        [ORG_SMS_KEYS.SMS_PHONE_NUMBER]: '+15551234567',
+        [ORG_SMS_KEYS.SMS_WEBHOOK_SECRET]: 'existing-webhook-secret',
+      })
+    )
+    integrationConnection.create.mockImplementation(({ data }) => ({
+      id: 'conn-1',
+      credentials: data.credentials,
+      settings: data.settings,
+      status: data.status,
+    }))
+
+    const setup = await channelSetup('org-1', 'sms')
+
+    expect(setup?.connectorId).toBe('twilio-sms')
+    expect(setup?.credentials.accountSid).toBe('AC123')
+    expect(setup?.credentials.authToken).toBe('secret-token')
+    // The vendor already points its webhook at this secret, so it is carried
+    // over rather than replaced.
+    expect(setup?.credentials.webhookSecret).toBe('existing-webhook-secret')
+    expect(setup?.settings.phoneNumber).toBe('+15551234567')
+
+    const created = integrationConnection.create.mock.calls[0][0].data
+    expect(created.status).toBe('active')
+    expect(created.credentials).not.toContain('secret-token')
+    expect(created.settings.webhookSecretHash).toBe(webhookSecretHash('existing-webhook-secret'))
+  })
+
+  it('hands the send path its values under the keys it already reads', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'telnyx',
+        [ORG_SMS_KEYS.SMS_TELNYX_API_KEY]: 'KEY123',
+        [ORG_SMS_KEYS.SMS_PHONE_NUMBER]: '+4791234567',
+      })
+    )
+    integrationConnection.create.mockImplementation(({ data }) => ({
+      id: 'conn-2',
+      credentials: data.credentials,
+      settings: data.settings,
+      status: data.status,
+    }))
+
+    const setup = await channelSetup('org-2', 'sms')
+    const map = asLegacyMap(setup!)
+
+    expect(map.get(ORG_SMS_KEYS.SMS_PROVIDER)).toBe('telnyx')
+    expect(map.get(ORG_SMS_KEYS.SMS_TELNYX_API_KEY)).toBe('KEY123')
+    expect(map.get(ORG_SMS_KEYS.SMS_PHONE_NUMBER)).toBe('+4791234567')
+  })
+
+  it('leaves a half-filled setup alone rather than connecting something that cannot send', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        // No auth token: the workshop never finished.
+      })
+    )
+
+    expect(await channelSetup('org-3', 'sms')).toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('says nothing is set up when nothing ever was', async () => {
+    appSetting.findMany.mockResolvedValue([])
+    expect(await channelSetup('org-4', 'whatsapp')).toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('leaves a marker so a channel is adopted once and a later disconnect sticks', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: 'secret-token',
+        [ORG_SMS_KEYS.SMS_PHONE_NUMBER]: '+15551234567',
+      })
+    )
+    integrationConnection.create.mockImplementation(({ data }) => ({
+      id: 'conn-1',
+      credentials: data.credentials,
+      settings: data.settings,
+      status: data.status,
+    }))
+
+    expect((await channelSetup('org-7', 'sms'))?.connectorId).toBe('twilio-sms')
+    const marker = appSetting.upsert.mock.calls[0][0]
+    expect(marker.create.key).toBe(adoptedMarkerKey('sms'))
+    expect(marker.create.userId).toBe('user-1')
+
+    // The workshop disconnects Twilio: the row is gone, the old settings are
+    // not, and the marker is what stops them coming back on the next send.
+    appSetting.findMany.mockResolvedValue([
+      ...legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: 'secret-token',
+      }),
+      { key: adoptedMarkerKey('sms'), value: '2026-09-02T00:00:00.000Z', userId: 'user-1' },
+    ])
+    integrationConnection.create.mockClear()
+
+    expect(await channelSetup('org-7', 'sms')).toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('does not adopt a vendor the workshop had switched away from', async () => {
+    // The old email form cleared the provider row to go back to the
+    // platform's mail, and left the Resend key where it was.
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        'email.provider': '',
+        'email.resend.apiKey': 're_123',
+        'email.resend.fromEmail': 'hi@example.com',
+      })
+    )
+
+    expect(await channelSetup('org-8', 'email')).toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('still adopts a Telegram bot, which never had a provider row', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        'telegram.botToken': '123:abc',
+        'telegram.botUsername': 'shopbot',
+        'telegram.webhookSecret': 'hook-secret',
+      })
+    )
+    integrationConnection.create.mockImplementation(({ data }) => ({
+      id: 'conn-t',
+      credentials: data.credentials,
+      settings: data.settings,
+      status: data.status,
+    }))
+
+    const setup = await channelSetup('org-9', 'telegram')
+    expect(setup?.connectorId).toBe('telegram')
+    expect(setup?.credentials.webhookSecret).toBe('hook-secret')
+    expect(setup?.settings.botUsername).toBe('shopbot')
+  })
+
+  it('applies the catalog defaults a fresh connection never saved', async () => {
+    const { sealCredentials } = await import('@/features/integrations/Lib/vault')
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-w',
+        connectorId: 'whatsapp-meta',
+        credentials: sealCredentials({ phoneNumberId: '1', accessToken: 't', verifyToken: 'v' }),
+        settings: { phoneNumber: '+4712345678' },
+      },
+    ])
+
+    const setup = await channelSetup('org-10', 'whatsapp')
+    expect(setup?.settings.enabled).toBe(true)
+    expect(asLegacyMap(setup!).get('whatsapp.enabled')).toBe('true')
+    expect(await channelEnabled('org-10', 'whatsapp')).toBe(true)
+  })
+
+  it('keeps a switched-off WhatsApp off after adoption', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        'whatsapp.provider': 'meta',
+        'whatsapp.enabled': 'false',
+        'whatsapp.from': '+4712345678',
+        [whatsappCredentialKey('meta', 'phoneNumberId')]: '1',
+        [whatsappCredentialKey('meta', 'accessToken')]: 't',
+        [whatsappCredentialKey('meta', 'verifyToken')]: 'v',
+      })
+    )
+    integrationConnection.create.mockImplementation(({ data }) => ({
+      id: 'conn-w2',
+      credentials: data.credentials,
+      settings: data.settings,
+      status: data.status,
+    }))
+
+    expect(await channelEnabled('org-11', 'whatsapp')).toBe(false)
+  })
+
+  it('mints the webhook secret and its fingerprint when keys come from the form', () => {
+    const done = completeMessagingCredentials('telnyx-sms', { apiKey: 'KEY' })
+    expect(done.credentials.apiKey).toBe('KEY')
+    expect(done.credentials.webhookSecret).toMatch(/^[0-9a-f]{48}$/)
+    expect(done.settings.webhookSecretHash).toBe(
+      webhookSecretHash(done.credentials.webhookSecret as string)
+    )
+    // Not a messaging connector: nothing to add.
+    expect(completeMessagingCredentials('google-calendar', { a: '1' })).toEqual({
+      credentials: { a: '1' },
+      settings: {},
+    })
+  })
+
+  it('prefers a live connection over the old rows', async () => {
+    const { sealCredentials } = await import('@/features/integrations/Lib/vault')
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-9',
+        connectorId: 'vonage-sms',
+        credentials: sealCredentials({ apiKey: 'new-key', apiSecret: 'new-secret' }),
+        settings: { phoneNumber: '+4712345678' },
+      },
+    ])
+
+    const setup = await channelSetup('org-5', 'sms')
+
+    expect(setup?.connectorId).toBe('vonage-sms')
+    expect(setup?.credentials.apiKey).toBe('new-key')
+    expect(appSetting.findMany).not.toHaveBeenCalled()
+  })
+
+  it('mints a webhook secret for a connection that has none yet', async () => {
+    const { sealCredentials } = await import('@/features/integrations/Lib/vault')
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-10',
+        connectorId: 'twilio-sms',
+        credentials: sealCredentials({ accountSid: 'AC1', authToken: 'tok' }),
+        settings: {},
+      },
+    ])
+
+    const setup = await channelSetup('org-6', 'sms')
+
+    expect(setup?.credentials.webhookSecret).toMatch(/^[0-9a-f]{48}$/)
+    expect(integrationConnection.update).toHaveBeenCalledOnce()
+    expect(setup?.settings.webhookSecretHash).toBe(
+      webhookSecretHash(setup?.credentials.webhookSecret as string)
+    )
+  })
+})
+
+describe('one vendor per channel', () => {
+  it('knows a connector by id', () => {
+    expect(messagingProvider('smtp')?.channel).toBe('email')
+    expect(messagingProvider('google-calendar')).toBeNull()
+  })
+
+  it('does not adopt over keys the workshop is fixing, and does not mark the channel', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: 'secret-token',
+      })
+    )
+    // A Twilio row already exists, in error after a failed check.
+    integrationConnection.findUnique.mockResolvedValue({ id: 'conn-err' })
+
+    expect(await channelSetup('org-12', 'sms')).toBeNull()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+    expect(appSetting.upsert).not.toHaveBeenCalled()
+  })
+
+  it('uses the winner of a first-send race instead of failing', async () => {
+    const { sealCredentials } = await import('@/features/integrations/Lib/vault')
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: 'secret-token',
+      })
+    )
+    integrationConnection.create.mockRejectedValue({ code: 'P2002' })
+    integrationConnection.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({
+      id: 'conn-winner',
+      credentials: sealCredentials({ accountSid: 'AC123', authToken: 'secret-token' }),
+      settings: {},
+      status: 'active',
+    })
+
+    const setup = await channelSetup('org-13', 'sms')
+    expect(setup?.connectionId).toBe('conn-winner')
+    expect(setup?.credentials.accountSid).toBe('AC123')
+  })
+
+  it('keeps sending from the old rows when a connection cannot be unsealed', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({
+        [ORG_SMS_KEYS.SMS_PROVIDER]: 'twilio',
+        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: 'AC123',
+        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: 'secret-token',
+        [ORG_SMS_KEYS.SMS_PHONE_NUMBER]: '+15551234567',
+      })
+    )
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-bad',
+        connectorId: 'twilio-sms',
+        credentials: 'v1.not.real.data',
+        settings: {},
+      },
+    ])
+    const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+    const setup = await channelSetup('org-14', 'sms')
+    expect(setup?.credentials.accountSid).toBe('AC123')
+    expect(setup?.settings.phoneNumber).toBe('+15551234567')
+    expect(error).toHaveBeenCalled()
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+    error.mockRestore()
+  })
+
+  it('names the vendor an unfinished email setup pointed at', async () => {
+    appSetting.findUnique.mockResolvedValue({ value: 'mailgun' })
+    expect(await legacyProviderNamed('org-15', 'email')).toBe('mailgun')
+    appSetting.findUnique.mockResolvedValue({ value: '' })
+    expect(await legacyProviderNamed('org-15', 'email')).toBeNull()
+    expect(await legacyProviderNamed('org-15', 'telegram')).toBeNull()
+  })
+})
+
+describe('inbound webhook lookup', () => {
+  it('resolves a connected workshop by the fingerprint of its secret', async () => {
+    integrationConnection.findFirst.mockResolvedValue({ organizationId: 'org-20' })
+    expect(await organizationForWebhookSecret('sms', 'abc', 'sms.webhookSecret')).toBe('org-20')
+    expect(integrationConnection.findFirst.mock.calls[0][0].where.settings.equals).toBe(
+      webhookSecretHash('abc')
+    )
+    expect(appSetting.findFirst).not.toHaveBeenCalled()
+  })
+
+  it('still resolves a workshop that has not been adopted yet from its old row', async () => {
+    appSetting.findFirst.mockResolvedValue({ organizationId: 'org-21' })
+    expect(await organizationForWebhookSecret('sms', 'old', 'sms.webhookSecret')).toBe('org-21')
+  })
+
+  it('stops answering for an old secret once the channel has moved on', async () => {
+    appSetting.findFirst.mockResolvedValue({ organizationId: 'org-22' })
+    appSetting.findUnique.mockResolvedValue({ id: 'marker' })
+    expect(await organizationForWebhookSecret('sms', 'old', 'sms.webhookSecret')).toBeNull()
+
+    appSetting.findUnique.mockResolvedValue(null)
+    integrationConnection.findFirst
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce({ id: 'conn-retired' })
+    expect(await organizationForWebhookSecret('sms', 'old', 'sms.webhookSecret')).toBeNull()
+  })
+
+  it('ignores an empty secret', async () => {
+    expect(await organizationForWebhookSecret('sms', '  ', 'sms.webhookSecret')).toBeNull()
+    expect(integrationConnection.findFirst).not.toHaveBeenCalled()
+  })
+})

+ 28 - 0
src/__tests__/features/sms/sms-send.test.ts

@@ -33,6 +33,20 @@ vi.mock('@/lib/db', () => ({
     appSetting: {
       findUnique: vi.fn(),
       findMany: vi.fn(),
+      // Adoption leaves a marker row behind so it only ever happens once.
+      upsert: vi.fn(),
+    },
+    // SMS credentials live on an integration connection now. These tests seed
+    // the old settings rows, so they run through the adoption path that a
+    // workshop configured before the move takes on its first send.
+    integrationConnection: {
+      findMany: vi.fn(),
+      findFirst: vi.fn(),
+      findUnique: vi.fn(),
+      create: vi.fn(),
+      upsert: vi.fn(),
+      update: vi.fn(),
+      updateMany: vi.fn(),
     },
   },
 }))
@@ -155,6 +169,20 @@ function mockTelnyxFailure() {
 
 beforeEach(() => {
   vi.resetAllMocks()
+  process.env.INTEGRATIONS_ENCRYPTION_KEY = 'a'.repeat(64)
+  // No connection yet, and no settings unless a test seeds them: the send path
+  // then reads the old rows and adopts them, which is what a workshop that
+  // configured SMS before the move goes through on its first send.
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([] as any)
+  vi.mocked(db.integrationConnection.findMany).mockResolvedValue([] as any)
+  vi.mocked(db.integrationConnection.update).mockResolvedValue({} as any)
+  vi.mocked(db.integrationConnection.findUnique).mockResolvedValue(null as any)
+  vi.mocked(db.integrationConnection.create).mockImplementation((({ data }: any) => ({
+    id: 'conn-test',
+    credentials: data.credentials,
+    settings: data.settings,
+    status: data.status,
+  })) as any)
 })
 
 // ═════════════════════════════════════════════════════════════════════════════

+ 6 - 24
src/app/(authenticated)/customers/[id]/page.tsx

@@ -12,7 +12,7 @@ import { getTelegramConversation } from '@/features/telegram/Actions/telegramAct
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures } from '@/lib/features'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
-import { db } from '@/lib/db'
+import { channelEnabled } from '@/features/integrations/Lib/messaging'
 import { CustomerDetailClient } from './customer-detail-client'
 import { PageHeader } from '@/components/page-header'
 
@@ -69,32 +69,14 @@ export default async function CustomerDetailPage({ params }: { params: Promise<{
     const features = await getFeatures(layoutData.organizationId)
     smsEnabled = features.sms
 
+    // Both switches live on the channel's integration now. The conversation
+    // itself loads client-side, so only the flag is needed here to decide
+    // whether the tab exists at all.
     if (features.telegram) {
-      const tgEnabledSetting = await db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId: layoutData.organizationId,
-            key: 'telegram.enabled',
-          },
-        },
-        select: { value: true },
-      })
-      telegramEnabled = tgEnabledSetting?.value === 'true'
+      telegramEnabled = await channelEnabled(layoutData.organizationId, 'telegram')
     }
-
-    // The conversation itself loads client-side, so only the flag is needed
-    // here to decide whether the tab exists at all.
     if (features.whatsapp) {
-      const waEnabledSetting = await db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId: layoutData.organizationId,
-            key: 'whatsapp.enabled',
-          },
-        },
-        select: { value: true },
-      })
-      whatsappEnabled = waEnabledSetting?.value === 'true'
+      whatsappEnabled = await channelEnabled(layoutData.organizationId, 'whatsapp')
     }
 
     if (smsEnabled && result.data.phone) {

+ 3 - 2
src/app/(authenticated)/settings/email/page.tsx

@@ -1,8 +1,9 @@
 import { redirect } from 'next/navigation'
 
 /**
- * Kept as a redirect: every channel now lives on one tabbed page, but docs,
- * notifications and feature hints still link to the old address.
+ * Kept as a redirect: the channel is an integration now, but docs,
+ * notifications and feature hints still link to the old address, and the
+ * providers page explains where it went.
  */
 export default function EmailSettingsPage() {
   redirect('/settings/providers?tab=email')

+ 252 - 89
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -9,7 +9,9 @@ import {
   AlertTriangle,
   ArrowLeft,
   Check,
+  Copy,
   Loader2,
+  Mail,
   Play,
   Plug,
   RefreshCw,
@@ -41,6 +43,7 @@ import {
   retryIntegrationJob,
   runIntegrationJob,
   saveIntegrationCredentials,
+  sendIntegrationTestMessage,
   testIntegration,
   updateIntegrationSettings,
 } from '@/features/integrations/Actions/integrationActions'
@@ -79,6 +82,7 @@ export function ConnectionSettings({
   const confirm = useConfirm()
   const { manifest, connection } = view
   const [busy, setBusy] = useState<string | null>(null)
+  const [copied, setCopied] = useState(false)
 
   // Outcome of an OAuth round trip lands here as a query parameter.
   useEffect(() => {
@@ -134,7 +138,11 @@ export function ConnectionSettings({
   }
 
   const firstSchedule = manifest.schedules?.[0]?.job
+  const hasSync = Boolean(firstSchedule)
   const isCalendar = manifest.category === 'calendar'
+  // Mail vendors can prove themselves by delivering a message to the person
+  // looking at the page, which a key check cannot.
+  const canSendTestEmail = manifest.capabilities.includes('email.send')
 
   return (
     <div className="space-y-4">
@@ -184,7 +192,7 @@ export function ConnectionSettings({
         )}
 
         {connected ? (
-          <div className="grid gap-4 text-sm sm:grid-cols-3">
+          <div className={`grid gap-4 text-sm ${hasSync ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
             <div>
               <p className="text-xs text-muted-foreground">{t('connection.account')}</p>
               <p className="font-medium">{connection.externalAccountName ?? '-'}</p>
@@ -200,17 +208,20 @@ export function ConnectionSettings({
                   : '-'}
               </p>
             </div>
-            <div>
-              <p className="text-xs text-muted-foreground">{t('connection.lastSync')}</p>
-              <p className="font-medium">
-                {connection.lastSyncAt
-                  ? format.dateTime(new Date(connection.lastSyncAt), {
-                      dateStyle: 'medium',
-                      timeStyle: 'short',
-                    })
-                  : '-'}
-              </p>
-            </div>
+            {/* Only connectors that sync on a timer have a last sync to show. */}
+            {hasSync && (
+              <div>
+                <p className="text-xs text-muted-foreground">{t('connection.lastSync')}</p>
+                <p className="font-medium">
+                  {connection.lastSyncAt
+                    ? format.dateTime(new Date(connection.lastSyncAt), {
+                        dateStyle: 'medium',
+                        timeStyle: 'short',
+                      })
+                    : '-'}
+                </p>
+              </div>
+            )}
           </div>
         ) : (
           <ConnectForm
@@ -221,16 +232,75 @@ export function ConnectionSettings({
             oauthStartUrl={oauthStartUrl}
             enabled={view.enabled}
             busy={busy}
-            onSave={(values) =>
+            initialSettings={connection?.settings}
+            onSave={(values, settings) =>
               run(
                 'credentials',
-                () => saveIntegrationCredentials(manifest.id, values),
+                () => saveIntegrationCredentials(manifest.id, values, settings),
                 isOAuth ? undefined : t('connection.connectedShort')
               )
             }
           />
         )}
 
+        {connected && view.inbound && (
+          <div className="mt-4 rounded-lg border bg-muted/30 p-3 text-sm">
+            <p className="font-medium">{t('connection.inboundUrl')}</p>
+            <p className="mt-1 text-xs text-muted-foreground">
+              {t(`connection.${view.inbound.note}`)}
+            </p>
+            <div className="mt-2 flex items-center gap-2">
+              <code className="block min-w-0 flex-1 select-all break-all rounded bg-background px-2 py-1 text-xs">
+                {view.inbound.url}
+              </code>
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() => {
+                  navigator.clipboard.writeText(view.inbound?.url ?? '')
+                  setCopied(true)
+                  setTimeout(() => setCopied(false), 2000)
+                }}
+              >
+                {copied ? (
+                  <Check className="mr-1 h-3.5 w-3.5" />
+                ) : (
+                  <Copy className="mr-1 h-3.5 w-3.5" />
+                )}
+                {copied ? t('connection.copied') : t('connection.copy')}
+              </Button>
+            </div>
+          </div>
+        )}
+
+        {/* Keys that failed their check can be entered again without
+            disconnecting first, which would also throw the settings away. */}
+        {connected && connection.status === 'error' && !isOAuth && (
+          <div className="mt-4 rounded-lg border p-3">
+            <p className="mb-3 text-sm">
+              <span className="font-medium">{t('connection.updateKeys')}</span>{' '}
+              <span className="text-muted-foreground">{t('connection.updateKeysHint')}</span>
+            </p>
+            <ConnectForm
+              manifest={manifest}
+              tenantClientId={null}
+              needsTenantApp={false}
+              redirectUri={view.redirectUri}
+              oauthStartUrl={oauthStartUrl}
+              enabled={view.enabled}
+              busy={busy}
+              initialSettings={connection.settings}
+              onSave={(values, settings) =>
+                run(
+                  'credentials',
+                  () => saveIntegrationCredentials(manifest.id, values, settings),
+                  t('connection.connectedShort')
+                )
+              }
+            />
+          </div>
+        )}
+
         {connected && (
           <div className="mt-4 flex flex-wrap gap-2">
             <Button
@@ -248,6 +318,30 @@ export function ConnectionSettings({
               )}
               {t('connection.test')}
             </Button>
+            {canSendTestEmail && (
+              <Button
+                variant="outline"
+                size="sm"
+                title={t('connection.testEmailHint')}
+                onClick={() =>
+                  run('sendTest', async () => {
+                    const res = await sendIntegrationTestMessage(manifest.id)
+                    if (res.success && res.data) {
+                      toast.success(t('connection.testEmailSent', { email: res.data.sentTo }))
+                    }
+                    return res
+                  })
+                }
+                disabled={busy !== null}
+              >
+                {busy === 'sendTest' ? (
+                  <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <Mail className="mr-1 h-3.5 w-3.5" />
+                )}
+                {t('connection.sendTestEmail')}
+              </Button>
+            )}
             {firstSchedule && (
               <Button
                 variant="outline"
@@ -364,6 +458,7 @@ function ConnectForm({
   oauthStartUrl,
   enabled,
   busy,
+  initialSettings,
   onSave,
 }: {
   manifest: ConnectionView['manifest']
@@ -373,7 +468,9 @@ function ConnectForm({
   oauthStartUrl: string
   enabled: boolean
   busy: string | null
-  onSave: (values: Record<string, string>) => Promise<boolean>
+  /** Settings already on the connection, when keys are being entered again. */
+  initialSettings?: Record<string, unknown>
+  onSave: (values: Record<string, string>, settings?: SettingValues) => Promise<boolean>
 }) {
   const t = useTranslations('integrations')
   const tc = useTranslations(`integrations.connectors.${manifest.id}`)
@@ -385,9 +482,23 @@ function ConnectForm({
       : manifest.auth.fields
   const [values, setValues] = useState<Record<string, string>>(() => {
     const initial: Record<string, string> = {}
+    for (const f of fields) if (f.default) initial[f.key] = f.default
     if (tenantClientId) initial.clientId = tenantClientId
     return initial
   })
+  // Key-based vendors take their settings on the same page as the keys: an
+  // SMTP port means nothing without its TLS choice, and a from address is
+  // needed before a test email can go out. Remote selects need a connection
+  // to list from, so they wait for the settings card.
+  const settingFields: SettingField[] =
+    manifest.auth.type === 'oauth2'
+      ? []
+      : manifest.settings.filter((f) => f.type !== 'remote-select')
+  const [settings, setSettings] = useState<SettingValues>(() =>
+    initialSettingValues(settingFields, initialSettings ?? {})
+  )
+  const visibleSettings = visibleSettingFields(settingFields, settings)
+  const settingsMissing = visibleSettings.some((f) => f.required && !settings[f.key])
   const tenantReady = Boolean(tenantClientId)
   const tenantComplete = Boolean(values.clientId) && (Boolean(values.clientSecret) || tenantReady)
   const tenantDirty = values.clientId !== (tenantClientId ?? '') || Boolean(values.clientSecret)
@@ -442,6 +553,18 @@ function ConnectForm({
         </div>
       )}
 
+      {visibleSettings.length > 0 && (
+        <div className="rounded-lg border p-3">
+          <SettingFieldList
+            connectorId={manifest.id}
+            fields={visibleSettings}
+            values={settings}
+            setValues={setSettings}
+            remote={{}}
+          />
+        </div>
+      )}
+
       <div className="flex flex-wrap gap-2">
         {manifest.auth.type === 'oauth2' ? (
           needsTenantApp ? (
@@ -463,8 +586,10 @@ function ConnectForm({
           )
         ) : (
           <Button
-            onClick={() => onSave(values)}
-            disabled={busy !== null || fields.some((f) => f.required && !values[f.key])}
+            onClick={() => onSave(values, settings)}
+            disabled={
+              busy !== null || settingsMissing || fields.some((f) => f.required && !values[f.key])
+            }
           >
             {busy === 'credentials' && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
             {t('connection.connect')}
@@ -475,6 +600,107 @@ function ConnectForm({
   )
 }
 
+type SettingValues = Record<string, string | number | boolean>
+
+/** Saved values under the manifest defaults, with an empty value for the rest. */
+function initialSettingValues(
+  fields: SettingField[],
+  initial: Record<string, unknown>
+): SettingValues {
+  const out: SettingValues = {}
+  for (const f of fields) {
+    const v = initial[f.key]
+    out[f.key] =
+      (v as string | number | boolean | undefined) ??
+      (f.default as string | number | boolean | undefined) ??
+      (f.type === 'boolean' ? false : '')
+  }
+  return out
+}
+
+function visibleSettingFields(fields: SettingField[], values: SettingValues): SettingField[] {
+  return fields.filter((f) => !f.showWhen || values[f.showWhen.key] === f.showWhen.equals)
+}
+
+/** The rows of a settings form, shared by the connect page and the settings card. */
+function SettingFieldList({
+  connectorId,
+  fields,
+  values,
+  setValues,
+  remote,
+}: {
+  connectorId: string
+  fields: SettingField[]
+  values: SettingValues
+  setValues: (update: (prev: SettingValues) => SettingValues) => void
+  remote: Record<string, SettingOption[] | null>
+}) {
+  const t = useTranslations('integrations')
+  const tc = useTranslations(`integrations.connectors.${connectorId}`)
+  return (
+    <div className="space-y-4">
+      {fields.map((f) => (
+        <div
+          key={f.key}
+          className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
+        >
+          <div className="min-w-0">
+            <Label className="text-sm">{tc(`settings.${f.label}`)}</Label>
+            {f.help && <p className="text-xs text-muted-foreground">{tc(`settings.${f.help}`)}</p>}
+          </div>
+          <div className="sm:w-72 sm:shrink-0">
+            {f.type === 'boolean' && (
+              <Switch
+                checked={Boolean(values[f.key])}
+                onCheckedChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
+              />
+            )}
+            {(f.type === 'text' || f.type === 'number') && (
+              <Input
+                type={f.type}
+                className="h-8"
+                value={String(values[f.key] ?? '')}
+                onChange={(e) =>
+                  setValues((s) => ({
+                    ...s,
+                    [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value,
+                  }))
+                }
+              />
+            )}
+            {(f.type === 'select' || f.type === 'remote-select') && (
+              <Select
+                value={String(values[f.key] ?? '')}
+                onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
+              >
+                <SelectTrigger className="h-8">
+                  <SelectValue
+                    placeholder={
+                      f.type === 'remote-select' && remote[f.source ?? ''] === undefined
+                        ? t('connection.loading')
+                        : t('connection.choose')
+                    }
+                  />
+                </SelectTrigger>
+                <SelectContent>
+                  {(f.type === 'select' ? (f.options ?? []) : (remote[f.source ?? ''] ?? [])).map(
+                    (o) => (
+                      <SelectItem key={o.value} value={o.value}>
+                        {o.label}
+                      </SelectItem>
+                    )
+                  )}
+                </SelectContent>
+              </Select>
+            )}
+          </div>
+        </div>
+      ))}
+    </div>
+  )
+}
+
 function SettingsForm({
   connectorId,
   fields,
@@ -489,18 +715,7 @@ function SettingsForm({
   busy: boolean
 }) {
   const t = useTranslations('integrations')
-  const tc = useTranslations(`integrations.connectors.${connectorId}`)
-  const [values, setValues] = useState<Record<string, string | number | boolean>>(() => {
-    const out: Record<string, string | number | boolean> = {}
-    for (const f of fields) {
-      const v = initial[f.key]
-      out[f.key] =
-        (v as string | number | boolean | undefined) ??
-        (f.default as string | number | boolean | undefined) ??
-        (f.type === 'boolean' ? false : '')
-    }
-    return out
-  })
+  const [values, setValues] = useState<SettingValues>(() => initialSettingValues(fields, initial))
   const [remote, setRemote] = useState<Record<string, SettingOption[] | null>>({})
 
   const remoteSources = useMemo(
@@ -521,7 +736,7 @@ function SettingsForm({
     }
   }, [connectorId, remoteSources])
 
-  const visible = fields.filter((f) => !f.showWhen || values[f.showWhen.key] === f.showWhen.equals)
+  const visible = visibleSettingFields(fields, values)
   const missing = visible.some((f) => f.required && !values[f.key])
 
   return (
@@ -530,65 +745,13 @@ function SettingsForm({
       description={t('connection.settingsDescription')}
     >
       <div className="space-y-4">
-        {visible.map((f) => (
-          <div
-            key={f.key}
-            className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
-          >
-            <div className="min-w-0">
-              <Label className="text-sm">{tc(`settings.${f.label}`)}</Label>
-              {f.help && (
-                <p className="text-xs text-muted-foreground">{tc(`settings.${f.help}`)}</p>
-              )}
-            </div>
-            <div className="sm:w-72 sm:shrink-0">
-              {f.type === 'boolean' && (
-                <Switch
-                  checked={Boolean(values[f.key])}
-                  onCheckedChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
-                />
-              )}
-              {(f.type === 'text' || f.type === 'number') && (
-                <Input
-                  type={f.type}
-                  className="h-8"
-                  value={String(values[f.key] ?? '')}
-                  onChange={(e) =>
-                    setValues((s) => ({
-                      ...s,
-                      [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value,
-                    }))
-                  }
-                />
-              )}
-              {(f.type === 'select' || f.type === 'remote-select') && (
-                <Select
-                  value={String(values[f.key] ?? '')}
-                  onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
-                >
-                  <SelectTrigger className="h-8">
-                    <SelectValue
-                      placeholder={
-                        f.type === 'remote-select' && remote[f.source ?? ''] === undefined
-                          ? t('connection.loading')
-                          : t('connection.choose')
-                      }
-                    />
-                  </SelectTrigger>
-                  <SelectContent>
-                    {(f.type === 'select' ? (f.options ?? []) : (remote[f.source ?? ''] ?? [])).map(
-                      (o) => (
-                        <SelectItem key={o.value} value={o.value}>
-                          {o.label}
-                        </SelectItem>
-                      )
-                    )}
-                  </SelectContent>
-                </Select>
-              )}
-            </div>
-          </div>
-        ))}
+        <SettingFieldList
+          connectorId={connectorId}
+          fields={visible}
+          values={values}
+          setValues={setValues}
+          remote={remote}
+        />
         <div className="flex justify-end">
           <Button onClick={() => onSave(values)} disabled={busy || missing}>
             {busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}

+ 3 - 8
src/app/(authenticated)/settings/invoice/page.tsx

@@ -2,6 +2,7 @@ import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures } from '@/lib/features'
 import { db } from '@/lib/db'
+import { channelEnabled } from '@/features/integrations/Lib/messaging'
 import {
   getInvoiceLayoutConfig,
   getQuoteLayoutConfig,
@@ -32,16 +33,10 @@ export default async function InvoiceSettingsPage() {
   const customFields =
     customFieldsResult.success && customFieldsResult.data ? customFieldsResult.data : []
 
-  // Check if Telegram is enabled (plan feature + user setting)
+  // Check if Telegram is enabled (plan feature + the switch on its integration)
   let telegramEnabled = false
   if (features.telegram) {
-    const tgSetting = await db.appSetting.findUnique({
-      where: {
-        organizationId_key: { organizationId: data.organizationId, key: 'telegram.enabled' },
-      },
-      select: { value: true },
-    })
-    telegramEnabled = tgSetting?.value === 'true'
+    telegramEnabled = await channelEnabled(data.organizationId, 'telegram')
   }
 
   // The preview is meant to look like this workshop's own paper, so it gets the

+ 70 - 99
src/app/(authenticated)/settings/providers/page.tsx

@@ -1,23 +1,23 @@
-import { redirect } from 'next/navigation'
+import { ArrowRight, Plug } from 'lucide-react'
 import { getTranslations } from 'next-intl/server'
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { channelSetup } from '@/features/integrations/Lib/messaging'
+import { type MessagingChannel, messagingProvider } from '@/integrations/messaging/catalog'
 import { getLayoutData } from '@/lib/get-layout-data'
-import { getFeatures, isCloudMode } from '@/lib/features'
-import { getEmailSettings } from '@/features/email/Actions/emailSettingsActions'
-import { getSmsSettings } from '@/features/sms/Actions/smsSettingsActions'
-import { getTelegramSettings } from '@/features/telegram/Actions/telegramSettingsActions'
-import { getWhatsappSettings } from '@/features/whatsapp/Actions/whatsappSettingsActions'
-import { EmailSettingsForm } from '@/features/email/Components/EmailSettingsForm'
-import { SmsSettingsForm } from '@/features/sms/Components/SmsSettingsForm'
-import { TelegramSettingsForm } from '@/features/telegram/Components/TelegramSettingsForm'
-import { WhatsappSettingsForm } from '@/features/whatsapp/Components/WhatsappSettingsForm'
-import { FeatureLockedMessage } from '../feature-locked-message'
-import { ProviderTabs, type ProviderPanel } from './provider-tabs'
+
+const CHANNELS: MessagingChannel[] = ['email', 'sms', 'whatsapp', 'telegram']
 
 /**
- * Every channel the workshop can reach customers on, in one place.
+ * Where the channel settings used to be.
  *
- * Each provider still loads its own settings on the server; the tabs only
- * decide which one is on screen.
+ * Email, SMS, WhatsApp and Telegram are integrations now, set up in the
+ * catalog alongside every other vendor. The page stays as a signpost rather
+ * than a redirect: a workshop that bookmarked it, or that remembers where SMS
+ * lived, gets told what happened and what it is connected to today, which a
+ * silent jump somewhere else would not do.
  */
 export default async function ProvidersSettingsPage() {
   const data = await getLayoutData()
@@ -25,92 +25,63 @@ export default async function ProvidersSettingsPage() {
   if (data.status === 'unauthenticated') redirect('/auth/sign-in')
   if (data.status === 'no-organization') redirect('/onboarding')
 
-  const [features, t] = await Promise.all([
-    getFeatures(data.organizationId),
-    getTranslations('settings.providers'),
-  ])
-  const isCloud = isCloudMode()
+  const t = await getTranslations('settings.providers')
 
-  const appUrl =
-    process.env.NEXT_PUBLIC_APP_URL ||
-    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const rows = await Promise.all(
+    CHANNELS.map(async (channel) => {
+      const setup = await channelSetup(data.organizationId, channel)
+      return {
+        channel,
+        connectorId: setup?.connectorId ?? null,
+        vendor: setup ? (messagingProvider(setup.connectorId)?.name ?? null) : null,
+      }
+    })
+  )
 
-  // A locked channel is still worth a tab: it tells a workshop the capability
-  // exists, which a hidden tab cannot.
-  const [email, sms, telegram, whatsapp] = await Promise.all([
-    features.smtp ? getEmailSettings() : null,
-    features.sms ? getSmsSettings() : null,
-    features.telegram ? getTelegramSettings() : null,
-    features.whatsapp ? getWhatsappSettings() : null,
-  ])
+  return (
+    <div className="mx-auto max-w-3xl space-y-6 p-4 md:p-6">
+      <div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
+        <div className="flex items-start gap-3">
+          <Plug className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-500" />
+          <div className="space-y-1">
+            <p className="font-medium text-amber-900 dark:text-amber-200">{t('moved.title')}</p>
+            <p className="text-sm text-amber-800 dark:text-amber-300">{t('moved.description')}</p>
+          </div>
+        </div>
+      </div>
 
-  const panels: ProviderPanel[] = [
-    {
-      key: 'email',
-      label: t('tabs.email'),
-      locked: !features.smtp,
-      content: features.smtp ? (
-        <EmailSettingsForm initial={email?.success && email.data ? email.data : {}} />
-      ) : (
-        <FeatureLockedMessage
-          feature={t('locked.email.feature')}
-          description={t('locked.email.description')}
-          isCloud={isCloud}
-        />
-      ),
-    },
-    {
-      key: 'sms',
-      label: t('tabs.sms'),
-      locked: !features.sms,
-      content: features.sms ? (
-        <SmsSettingsForm initial={sms?.success && sms.data ? sms.data : {}} appUrl={appUrl} />
-      ) : (
-        <FeatureLockedMessage
-          feature={t('locked.sms.feature')}
-          description={t('locked.sms.description')}
-          isCloud={isCloud}
-        />
-      ),
-    },
-    {
-      key: 'whatsapp',
-      label: t('tabs.whatsapp'),
-      locked: !features.whatsapp,
-      content:
-        features.whatsapp && whatsapp?.success && whatsapp.data ? (
-          <WhatsappSettingsForm initial={whatsapp.data} />
-        ) : (
-          <FeatureLockedMessage
-            feature={t('locked.whatsapp.feature')}
-            description={t('locked.whatsapp.description')}
-            isCloud={isCloud}
-          />
-        ),
-    },
-    {
-      key: 'telegram',
-      label: t('tabs.telegram'),
-      locked: !features.telegram,
-      content: features.telegram ? (
-        <TelegramSettingsForm
-          initial={telegram?.success && telegram.data ? telegram.data : {}}
-          appUrl={appUrl}
-          initialEnabled={
-            telegram?.success && telegram.data
-              ? telegram.data['telegram.enabled'] === 'true'
-              : false
-          }
-        />
-      ) : (
-        <FeatureLockedMessage
-          feature={t('locked.telegram.feature')}
-          description={t('locked.telegram.description')}
-          isCloud={isCloud}
-        />
-      ),
-    },
-  ]
+      <div className="divide-y rounded-lg border">
+        {rows.map((row) => (
+          <div key={row.channel} className="flex items-center justify-between gap-4 p-4">
+            <div className="min-w-0">
+              <p className="font-medium">{t(`tabs.${row.channel}`)}</p>
+              <p className="truncate text-sm text-muted-foreground">
+                {row.vendor
+                  ? t('moved.connectedTo', { vendor: row.vendor })
+                  : row.channel === 'email'
+                    ? t('moved.platformMail')
+                    : t('moved.notSetUp')}
+              </p>
+            </div>
+            {row.connectorId ? (
+              <Button asChild variant="outline" size="sm">
+                <Link href={`/settings/integrations/${row.connectorId}`}>{t('moved.manage')}</Link>
+              </Button>
+            ) : (
+              <Badge variant="secondary">
+                {row.channel === 'email' ? t('moved.onByDefault') : t('moved.notConnected')}
+              </Badge>
+            )}
+          </div>
+        ))}
+      </div>
 
-  return <ProviderTabs panels={panels} defaultTab="email" />
+      <Button asChild>
+        <Link href="/settings/integrations">
+          {t('moved.goToIntegrations')}
+          <ArrowRight className="ml-2 h-4 w-4" />
+        </Link>
+      </Button>
+    </div>
+  )
 }

+ 0 - 70
src/app/(authenticated)/settings/providers/provider-tabs.tsx

@@ -1,70 +0,0 @@
-'use client'
-
-import type { ReactNode } from 'react'
-import { useRouter, useSearchParams } from 'next/navigation'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { Lock } from 'lucide-react'
-
-export interface ProviderPanel {
-  key: string
-  label: string
-  /** Rendered on the server and handed over already built. */
-  content: ReactNode
-  /** The plan does not include this channel; the tab stays visible and explains why. */
-  locked?: boolean
-}
-
-/**
- * One page for every channel a workshop can reach customers on.
- *
- * They were separate sidebar entries, which put four near-identical pages in a
- * list that a workshop reads top to bottom looking for the one it wants. As
- * tabs they sit side by side, which is how they are actually compared.
- *
- * The active tab lives in the URL so a link to a specific provider, and a
- * reload after saving, both land where the workshop was.
- */
-export function ProviderTabs({
-  panels,
-  defaultTab,
-}: {
-  panels: ProviderPanel[]
-  defaultTab: string
-}) {
-  const router = useRouter()
-  const searchParams = useSearchParams()
-
-  const requested = searchParams.get('tab')
-  const active = panels.some((panel) => panel.key === requested)
-    ? (requested as string)
-    : defaultTab
-
-  return (
-    <Tabs
-      value={active}
-      onValueChange={(next) => {
-        const params = new URLSearchParams(searchParams.toString())
-        params.set('tab', next)
-        // Replace rather than push: flicking through tabs should not fill the
-        // back button with settings pages.
-        router.replace(`?${params.toString()}`, { scroll: false })
-      }}
-      className="w-full"
-    >
-      <TabsList className="mb-6 w-full justify-start overflow-x-auto">
-        {panels.map((panel) => (
-          <TabsTrigger key={panel.key} value={panel.key} className="gap-1.5">
-            {panel.locked && <Lock className="h-3 w-3 opacity-60" />}
-            {panel.label}
-          </TabsTrigger>
-        ))}
-      </TabsList>
-
-      {panels.map((panel) => (
-        <TabsContent key={panel.key} value={panel.key} className="mt-0">
-          {panel.content}
-        </TabsContent>
-      ))}
-    </Tabs>
-  )
-}

+ 3 - 2
src/app/(authenticated)/settings/sms/page.tsx

@@ -1,8 +1,9 @@
 import { redirect } from 'next/navigation'
 
 /**
- * Kept as a redirect: every channel now lives on one tabbed page, but docs,
- * notifications and feature hints still link to the old address.
+ * Kept as a redirect: the channel is an integration now, but docs,
+ * notifications and feature hints still link to the old address, and the
+ * providers page explains where it went.
  */
 export default function SmsSettingsPage() {
   redirect('/settings/providers?tab=sms')

+ 3 - 2
src/app/(authenticated)/settings/telegram/page.tsx

@@ -1,8 +1,9 @@
 import { redirect } from 'next/navigation'
 
 /**
- * Kept as a redirect: every channel now lives on one tabbed page, but docs,
- * notifications and feature hints still link to the old address.
+ * Kept as a redirect: the channel is an integration now, but docs,
+ * notifications and feature hints still link to the old address, and the
+ * providers page explains where it went.
  */
 export default function TelegramSettingsPage() {
   redirect('/settings/providers?tab=telegram')

+ 3 - 2
src/app/(authenticated)/settings/whatsapp/page.tsx

@@ -1,8 +1,9 @@
 import { redirect } from 'next/navigation'
 
 /**
- * Kept as a redirect: every channel now lives on one tabbed page, but docs,
- * notifications and feature hints still link to the old address.
+ * Kept as a redirect: the channel is an integration now, but docs,
+ * notifications and feature hints still link to the old address, and the
+ * providers page explains where it went.
  */
 export default function WhatsappSettingsPage() {
   redirect('/settings/providers?tab=whatsapp')

+ 4 - 1
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -12,6 +12,7 @@ import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
 import type { Metadata } from 'next'
 import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
+import { getOrgTelegramBotUsername } from '@/lib/telegram'
 
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
 function toPublicFileUrl(fileUrl: string, token: string): string {
@@ -288,7 +289,9 @@ export default async function PublicInvoicePage({
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
 
-  const telegramBotUsername = settingsMap['telegram.botUsername'] || ''
+  // The bot follows the Telegram integration, whichever side of the move it
+  // was connected on.
+  const telegramBotUsername = (await getOrgTelegramBotUsername(record.organizationId)) || ''
   const telegramBotLink = telegramBotUsername ? `https://t.me/${telegramBotUsername}` : undefined
 
   return (

+ 2 - 1
src/app/api/protected/services/[id]/pdf/route.ts

@@ -16,6 +16,7 @@ import { formatDateForPdf } from '@/lib/format'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { markInvoiceIssued } from '@/features/onboarding/Lib/markInvoiceIssued'
 import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
+import { getOrgTelegramBotUsername } from '@/lib/telegram'
 
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
   try {
@@ -303,7 +304,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
 
     // Generate Telegram QR if the telegram_qr section is visible in layout
     let telegramQrDataUri: string | undefined
-    const telegramBotUsername = settingsMap['telegram.botUsername']
+    const telegramBotUsername = await getOrgTelegramBotUsername(ctx.organizationId)
     const telegramQrVisible = layoutConfig.sections.some((s) => s.id === 'telegram_qr' && s.visible)
     if (telegramBotUsername && telegramQrVisible) {
       const { generateQrDataUri } = await import('@/lib/qr')

+ 9 - 10
src/app/api/webhooks/sms/telnyx/route.ts

@@ -1,5 +1,6 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
+import { organizationForWebhookSecret } from '@/features/integrations/Lib/messaging'
 import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
 import { notify } from '@/lib/notify'
 
@@ -12,20 +13,18 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'Missing org_secret' }, { status: 400 })
     }
 
-    const secretSetting = await db.appSetting.findFirst({
-      where: {
-        key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
-        value: orgSecret,
-      },
-      select: { organizationId: true },
-    })
+    // The secret in the URL belongs to a connection, or to the row it lived
+    // in before SMS moved into Integrations.
+    const organizationId = await organizationForWebhookSecret(
+      'sms',
+      orgSecret,
+      ORG_SMS_KEYS.SMS_WEBHOOK_SECRET
+    )
 
-    if (!secretSetting?.organizationId) {
+    if (!organizationId) {
       return NextResponse.json({ error: 'Invalid org_secret' }, { status: 403 })
     }
 
-    const organizationId = secretSetting.organizationId
-
     // Telnyx sends JSON with a data wrapper
     const payload = (await request.json()) as {
       data?: {

+ 9 - 11
src/app/api/webhooks/sms/twilio/route.ts

@@ -1,5 +1,6 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
+import { organizationForWebhookSecret } from '@/features/integrations/Lib/messaging'
 import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
 import { notify } from '@/lib/notify'
 
@@ -12,21 +13,18 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'Missing org_secret' }, { status: 400 })
     }
 
-    // Look up org by webhook secret
-    const secretSetting = await db.appSetting.findFirst({
-      where: {
-        key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
-        value: orgSecret,
-      },
-      select: { organizationId: true },
-    })
+    // The secret in the URL belongs to a connection, or to the row it lived
+    // in before SMS moved into Integrations.
+    const organizationId = await organizationForWebhookSecret(
+      'sms',
+      orgSecret,
+      ORG_SMS_KEYS.SMS_WEBHOOK_SECRET
+    )
 
-    if (!secretSetting?.organizationId) {
+    if (!organizationId) {
       return NextResponse.json({ error: 'Invalid org_secret' }, { status: 403 })
     }
 
-    const organizationId = secretSetting.organizationId
-
     // Parse Twilio form-encoded payload
     const formData = await request.formData()
     const from = formData.get('From') as string

+ 7 - 10
src/app/api/webhooks/sms/vonage/route.ts

@@ -1,5 +1,6 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
+import { organizationForWebhookSecret } from '@/features/integrations/Lib/messaging'
 import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
 import { notify } from '@/lib/notify'
 
@@ -12,20 +13,16 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'Missing org_secret' }, { status: 400 })
     }
 
-    const secretSetting = await db.appSetting.findFirst({
-      where: {
-        key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
-        value: orgSecret,
-      },
-      select: { organizationId: true },
-    })
+    const organizationId = await organizationForWebhookSecret(
+      'sms',
+      orgSecret,
+      ORG_SMS_KEYS.SMS_WEBHOOK_SECRET
+    )
 
-    if (!secretSetting?.organizationId) {
+    if (!organizationId) {
       return NextResponse.json({ error: 'Invalid org_secret' }, { status: 403 })
     }
 
-    const organizationId = secretSetting.organizationId
-
     // Vonage sends JSON
     const payload = (await request.json()) as {
       msisdn?: string

+ 13 - 11
src/app/api/webhooks/telegram/[organizationId]/route.ts

@@ -1,7 +1,6 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
-import { ORG_TELEGRAM_KEYS } from '@/features/telegram/Schema/telegramSettingsSchema'
-import { sendTelegramMessage } from '@/lib/telegram'
+import { getOrgTelegramWebhookSecret, sendTelegramMessage } from '@/lib/telegram'
 import { notify } from '@/lib/notify'
 
 interface TelegramUpdate {
@@ -28,16 +27,19 @@ export async function POST(
       return NextResponse.json({ ok: true })
     }
 
-    const secretSetting = await db.appSetting.findUnique({
-      where: {
-        organizationId_key: {
-          organizationId,
-          key: ORG_TELEGRAM_KEYS.TELEGRAM_WEBHOOK_SECRET,
-        },
-      },
-    })
+    // The secret follows the Telegram integration, so a bot connected before
+    // the move and one connected after are checked the same way. Not being
+    // able to read it is our fault, not a bad caller: answer 500 so Telegram
+    // keeps the update and retries, rather than 200 which drops it for good.
+    let secret: string | null
+    try {
+      secret = await getOrgTelegramWebhookSecret(organizationId)
+    } catch (error) {
+      console.error('[webhook/telegram] Could not read the webhook secret:', error)
+      return NextResponse.json({ ok: false }, { status: 500 })
+    }
 
-    if (!secretSetting?.value || secretSetting.value !== secretHeader) {
+    if (!secret || secret !== secretHeader) {
       return NextResponse.json({ ok: true })
     }
 

+ 0 - 100
src/features/email/Actions/emailSettingsActions.ts

@@ -1,100 +0,0 @@
-'use server'
-
-import { db } from '@/lib/db'
-import { withAuth } from '@/lib/with-auth'
-import { revalidatePath } from 'next/cache'
-import { ALL_ORG_EMAIL_KEYS } from '../Schema/emailSettingsSchema'
-import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
-import { demoGuard } from '@/lib/demo'
-
-export async function getEmailSettings() {
-  return withAuth(
-    async ({ organizationId }) => {
-      const settings = await db.appSetting.findMany({
-        where: { organizationId, key: { in: ALL_ORG_EMAIL_KEYS } },
-      })
-      const map: Record<string, string> = {}
-      for (const s of settings) {
-        map[s.key] = s.value
-      }
-      return map
-    },
-    {
-      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
-    }
-  )
-}
-
-export async function setEmailSettings(entries: Record<string, string>) {
-  return withAuth(
-    async ({ userId, organizationId }) => {
-      demoGuard()
-      await db.$transaction(
-        Object.entries(entries).map(([key, value]) =>
-          db.appSetting.upsert({
-            where: { organizationId_key: { organizationId, key } },
-            update: { value },
-            create: { userId, organizationId, key, value },
-          })
-        )
-      )
-      revalidatePath('/settings/email')
-      return true
-    },
-    {
-      requiredPermissions: [
-        {
-          action: PermissionAction.UPDATE,
-          subject: PermissionSubject.SETTINGS,
-        },
-      ],
-    }
-  )
-}
-
-export async function testOrgEmailConnection() {
-  return withAuth(
-    async ({ userId, organizationId }) => {
-      demoGuard()
-      const user = await db.user.findUnique({
-        where: { id: userId },
-        select: { email: true },
-      })
-
-      if (!user?.email) {
-        throw new Error('Could not find your email address')
-      }
-
-      const from = await getOrgFromAddress(organizationId)
-
-      await sendOrgMail(organizationId, {
-        from,
-        to: user.email,
-        subject: 'Email Test - Torqvoice',
-        html: `
-          <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
-            <h2>Email Configuration Test</h2>
-            <p>This is a test email from your organization's email settings.</p>
-            <p>If you're reading this, your email provider is configured correctly.</p>
-            <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
-            <p style="color: #6b7280; font-size: 12px;">
-              Sent to: ${user.email}<br/>
-              Time: ${new Date().toISOString()}
-            </p>
-          </div>
-        `,
-      })
-
-      return { sentTo: user.email }
-    },
-    {
-      requiredPermissions: [
-        {
-          action: PermissionAction.UPDATE,
-          subject: PermissionSubject.SETTINGS,
-        },
-      ],
-    }
-  )
-}

+ 0 - 713
src/features/email/Components/EmailSettingsForm.tsx

@@ -1,713 +0,0 @@
-'use client'
-
-import { useState, useTransition } from 'react'
-import { useTranslations } from 'next-intl'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
-import { AppCard } from '@/components/app-card'
-import { Loader2, Send, Info } from 'lucide-react'
-import { ORG_EMAIL_KEYS } from '../Schema/emailSettingsSchema'
-import { setEmailSettings, testOrgEmailConnection } from '../Actions/emailSettingsActions'
-import {
-  ReadOnlyBanner,
-  SaveButton,
-  ReadOnlyWrapper,
-} from '@/app/(authenticated)/settings/read-only-guard'
-
-type EmailProviderType = 'smtp' | 'resend' | 'postmark' | 'mailgun' | 'sendgrid' | 'ses'
-
-export function EmailSettingsForm({ initial }: { initial: Record<string, string> }) {
-  const t = useTranslations('settings')
-  const router = useRouter()
-  const [isPending, startTransition] = useTransition()
-  const [isTesting, setIsTesting] = useState(false)
-
-  const hasCustomProvider = !!initial[ORG_EMAIL_KEYS.EMAIL_PROVIDER]
-  const [useCustom, setUseCustom] = useState(hasCustomProvider)
-
-  // Provider
-  const [emailProvider, setEmailProvider] = useState<EmailProviderType>(
-    (initial[ORG_EMAIL_KEYS.EMAIL_PROVIDER] as EmailProviderType) || 'smtp'
-  )
-
-  // SMTP
-  const [smtpHost, setSmtpHost] = useState(initial[ORG_EMAIL_KEYS.EMAIL_SMTP_HOST] || '')
-  const [smtpPort, setSmtpPort] = useState(initial[ORG_EMAIL_KEYS.EMAIL_SMTP_PORT] || '587')
-  const [smtpUser, setSmtpUser] = useState(initial[ORG_EMAIL_KEYS.EMAIL_SMTP_USER] || '')
-  const [smtpPass, setSmtpPass] = useState(initial[ORG_EMAIL_KEYS.EMAIL_SMTP_PASS] || '')
-  const [smtpSecure, setSmtpSecure] = useState(initial[ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE] === 'true')
-  const [smtpFromEmail, setSmtpFromEmail] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_EMAIL] || ''
-  )
-  const [smtpFromName, setSmtpFromName] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_NAME] || ''
-  )
-  const [smtpRejectUnauthorized, setSmtpRejectUnauthorized] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED] !== 'false'
-  )
-  const [smtpRequireTls, setSmtpRequireTls] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS] === 'true'
-  )
-
-  // Resend
-  const [resendApiKey, setResendApiKey] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY] || ''
-  )
-  const [resendFromEmail, setResendFromEmail] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_EMAIL] || ''
-  )
-  const [resendFromName, setResendFromName] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_NAME] || ''
-  )
-
-  // Postmark
-  const [postmarkApiKey, setPostmarkApiKey] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY] || ''
-  )
-  const [postmarkFromEmail, setPostmarkFromEmail] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_EMAIL] || ''
-  )
-  const [postmarkFromName, setPostmarkFromName] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_NAME] || ''
-  )
-
-  // Mailgun
-  const [mailgunApiKey, setMailgunApiKey] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY] || ''
-  )
-  const [mailgunDomain, setMailgunDomain] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_DOMAIN] || ''
-  )
-  const [mailgunRegion, setMailgunRegion] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_REGION] || 'us'
-  )
-  const [mailgunFromEmail, setMailgunFromEmail] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_EMAIL] || ''
-  )
-  const [mailgunFromName, setMailgunFromName] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_NAME] || ''
-  )
-
-  // SendGrid
-  const [sendgridApiKey, setSendgridApiKey] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY] || ''
-  )
-  const [sendgridFromEmail, setSendgridFromEmail] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_EMAIL] || ''
-  )
-  const [sendgridFromName, setSendgridFromName] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_NAME] || ''
-  )
-
-  // Amazon SES
-  const [sesAccessKeyId, setSesAccessKeyId] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID] || ''
-  )
-  const [sesSecretAccessKey, setSesSecretAccessKey] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SES_SECRET_ACCESS_KEY] || ''
-  )
-  const [sesRegion, setSesRegion] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SES_REGION] || 'us-east-1'
-  )
-  const [sesFromEmail, setSesFromEmail] = useState(
-    initial[ORG_EMAIL_KEYS.EMAIL_SES_FROM_EMAIL] || ''
-  )
-  const [sesFromName, setSesFromName] = useState(initial[ORG_EMAIL_KEYS.EMAIL_SES_FROM_NAME] || '')
-
-  const handleSave = () => {
-    startTransition(async () => {
-      if (!useCustom) {
-        // Clear the provider key to revert to platform default
-        const result = await setEmailSettings({
-          [ORG_EMAIL_KEYS.EMAIL_PROVIDER]: '',
-        })
-        if (result.success) {
-          toast.success(t('email.savedDefault'))
-          router.refresh()
-        } else {
-          toast.error(result.error ?? t('email.failedSave'))
-        }
-        return
-      }
-
-      const data: Record<string, string> = {
-        [ORG_EMAIL_KEYS.EMAIL_PROVIDER]: emailProvider,
-        // SMTP
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_HOST]: smtpHost,
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_PORT]: smtpPort,
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_USER]: smtpUser,
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_PASS]: smtpPass,
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE]: String(smtpSecure),
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_EMAIL]: smtpFromEmail,
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_NAME]: smtpFromName,
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED]: String(smtpRejectUnauthorized),
-        [ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS]: String(smtpRequireTls),
-        // Resend
-        [ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY]: resendApiKey,
-        [ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_EMAIL]: resendFromEmail,
-        [ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_NAME]: resendFromName,
-        // Postmark
-        [ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY]: postmarkApiKey,
-        [ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_EMAIL]: postmarkFromEmail,
-        [ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_NAME]: postmarkFromName,
-        // Mailgun
-        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY]: mailgunApiKey,
-        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_DOMAIN]: mailgunDomain,
-        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_REGION]: mailgunRegion,
-        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_EMAIL]: mailgunFromEmail,
-        [ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_NAME]: mailgunFromName,
-        // SendGrid
-        [ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY]: sendgridApiKey,
-        [ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_EMAIL]: sendgridFromEmail,
-        [ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_NAME]: sendgridFromName,
-        // Amazon SES
-        [ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID]: sesAccessKeyId,
-        [ORG_EMAIL_KEYS.EMAIL_SES_SECRET_ACCESS_KEY]: sesSecretAccessKey,
-        [ORG_EMAIL_KEYS.EMAIL_SES_REGION]: sesRegion,
-        [ORG_EMAIL_KEYS.EMAIL_SES_FROM_EMAIL]: sesFromEmail,
-        [ORG_EMAIL_KEYS.EMAIL_SES_FROM_NAME]: sesFromName,
-      }
-
-      const result = await setEmailSettings(data)
-      if (result.success) {
-        toast.success(t('email.saved'))
-        router.refresh()
-      } else {
-        toast.error(result.error ?? t('email.failedSave'))
-      }
-    })
-  }
-
-  const handleTestEmail = async () => {
-    setIsTesting(true)
-    try {
-      const result = await testOrgEmailConnection()
-      if (result.success) {
-        toast.success(t('email.testSentTo', { email: result.data?.sentTo ?? '' }))
-      } else {
-        toast.error(result.error ?? t('email.testFailed'))
-      }
-    } finally {
-      setIsTesting(false)
-    }
-  }
-
-  const isTestDisabled =
-    isTesting ||
-    !useCustom ||
-    (emailProvider === 'smtp' && !smtpHost) ||
-    (emailProvider === 'resend' && !resendApiKey) ||
-    (emailProvider === 'postmark' && !postmarkApiKey) ||
-    (emailProvider === 'mailgun' && !mailgunApiKey) ||
-    (emailProvider === 'sendgrid' && !sendgridApiKey) ||
-    (emailProvider === 'ses' && !sesAccessKeyId)
-
-  return (
-    <div className="space-y-6">
-      <ReadOnlyBanner />
-      <ReadOnlyWrapper>
-        <AppCard
-          title={t('email.title')}
-          description={t('email.description')}
-          contentClassName="space-y-6"
-        >
-          <div className="flex items-center justify-between">
-            <div className="space-y-0.5">
-              <Label htmlFor="use-custom-email">{t('email.useCustomLabel')}</Label>
-              <p className="text-xs text-muted-foreground">{t('email.useCustomHint')}</p>
-            </div>
-            <Switch id="use-custom-email" checked={useCustom} onCheckedChange={setUseCustom} />
-          </div>
-
-          {!useCustom && (
-            <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
-              <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
-              <p className="text-sm text-muted-foreground">{t('email.platformDefaultInfo')}</p>
-            </div>
-          )}
-
-          {useCustom && (
-            <>
-              <div className="flex flex-wrap gap-2">
-                <Button
-                  type="button"
-                  variant={emailProvider === 'smtp' ? 'default' : 'outline'}
-                  onClick={() => setEmailProvider('smtp')}
-                  className="flex-1"
-                >
-                  SMTP
-                </Button>
-                <Button
-                  type="button"
-                  variant={emailProvider === 'resend' ? 'default' : 'outline'}
-                  onClick={() => setEmailProvider('resend')}
-                  className="flex-1"
-                >
-                  Resend
-                </Button>
-                <Button
-                  type="button"
-                  variant={emailProvider === 'postmark' ? 'default' : 'outline'}
-                  onClick={() => setEmailProvider('postmark')}
-                  className="flex-1"
-                >
-                  Postmark
-                </Button>
-                <Button
-                  type="button"
-                  variant={emailProvider === 'mailgun' ? 'default' : 'outline'}
-                  onClick={() => setEmailProvider('mailgun')}
-                  className="flex-1"
-                >
-                  Mailgun
-                </Button>
-                <Button
-                  type="button"
-                  variant={emailProvider === 'sendgrid' ? 'default' : 'outline'}
-                  onClick={() => setEmailProvider('sendgrid')}
-                  className="flex-1"
-                >
-                  SendGrid
-                </Button>
-                <Button
-                  type="button"
-                  variant={emailProvider === 'ses' ? 'default' : 'outline'}
-                  onClick={() => setEmailProvider('ses')}
-                  className="flex-1"
-                >
-                  Amazon SES
-                </Button>
-              </div>
-
-              {emailProvider === 'smtp' && (
-                <>
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-smtp-host">{t('email.smtpHost')}</Label>
-                      <Input
-                        id="org-smtp-host"
-                        placeholder={t('email.smtpHostPlaceholder')}
-                        value={smtpHost}
-                        onChange={(e) => setSmtpHost(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-smtp-port">{t('email.smtpPort')}</Label>
-                      <Input
-                        id="org-smtp-port"
-                        placeholder={t('email.smtpPortPlaceholder')}
-                        value={smtpPort}
-                        onChange={(e) => setSmtpPort(e.target.value)}
-                      />
-                    </div>
-                  </div>
-
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-smtp-user">{t('email.username')}</Label>
-                      <Input
-                        id="org-smtp-user"
-                        placeholder={t('email.usernamePlaceholder')}
-                        value={smtpUser}
-                        onChange={(e) => setSmtpUser(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-smtp-pass">{t('email.password')}</Label>
-                      <Input
-                        id="org-smtp-pass"
-                        type="password"
-                        placeholder="••••••••"
-                        value={smtpPass}
-                        onChange={(e) => setSmtpPass(e.target.value)}
-                      />
-                    </div>
-                  </div>
-
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-smtp-from-email">{t('email.fromEmail')}</Label>
-                      <Input
-                        id="org-smtp-from-email"
-                        placeholder={t('email.fromEmailPlaceholder')}
-                        value={smtpFromEmail}
-                        onChange={(e) => setSmtpFromEmail(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-smtp-from-name">{t('email.fromName')}</Label>
-                      <Input
-                        id="org-smtp-from-name"
-                        placeholder={t('email.fromNamePlaceholder')}
-                        value={smtpFromName}
-                        onChange={(e) => setSmtpFromName(e.target.value)}
-                      />
-                    </div>
-                  </div>
-
-                  <div className="space-y-4">
-                    <div className="flex items-center justify-between">
-                      <div className="space-y-0.5">
-                        <Label htmlFor="org-smtp-secure">{t('email.tlsConnection')}</Label>
-                        <p className="text-xs text-muted-foreground">
-                          {t('email.tlsConnectionHint')}
-                        </p>
-                      </div>
-                      <Switch
-                        id="org-smtp-secure"
-                        checked={smtpSecure}
-                        onCheckedChange={setSmtpSecure}
-                      />
-                    </div>
-
-                    <div className="flex items-center justify-between">
-                      <div className="space-y-0.5">
-                        <Label htmlFor="org-smtp-reject-unauthorized">
-                          {t('email.verifyTlsCerts')}
-                        </Label>
-                        <p className="text-xs text-muted-foreground">
-                          {t('email.verifyTlsCertsHint')}
-                        </p>
-                      </div>
-                      <Switch
-                        id="org-smtp-reject-unauthorized"
-                        checked={smtpRejectUnauthorized}
-                        onCheckedChange={setSmtpRejectUnauthorized}
-                      />
-                    </div>
-
-                    <div className="flex items-center justify-between">
-                      <div className="space-y-0.5">
-                        <Label htmlFor="org-smtp-require-tls">{t('email.requireTlsUpgrade')}</Label>
-                        <p className="text-xs text-muted-foreground">
-                          {t('email.requireTlsUpgradeHint')}
-                        </p>
-                      </div>
-                      <Switch
-                        id="org-smtp-require-tls"
-                        checked={smtpRequireTls}
-                        onCheckedChange={setSmtpRequireTls}
-                      />
-                    </div>
-                  </div>
-                </>
-              )}
-
-              {emailProvider === 'resend' && (
-                <>
-                  <div className="space-y-2">
-                    <Label htmlFor="org-resend-api-key">{t('email.apiKey')}</Label>
-                    <Input
-                      id="org-resend-api-key"
-                      type="password"
-                      placeholder="re_••••••••"
-                      value={resendApiKey}
-                      onChange={(e) => setResendApiKey(e.target.value)}
-                    />
-                    <p className="text-xs text-muted-foreground">
-                      {t('email.apiKeyHintResend')}{' '}
-                      <a
-                        href="https://resend.com"
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        className="underline"
-                      >
-                        resend.com
-                      </a>
-                    </p>
-                  </div>
-
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-resend-from-email">{t('email.fromEmail')}</Label>
-                      <Input
-                        id="org-resend-from-email"
-                        placeholder={t('email.fromEmailDomainPlaceholder')}
-                        value={resendFromEmail}
-                        onChange={(e) => setResendFromEmail(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-resend-from-name">{t('email.fromName')}</Label>
-                      <Input
-                        id="org-resend-from-name"
-                        placeholder={t('email.fromNamePlaceholder')}
-                        value={resendFromName}
-                        onChange={(e) => setResendFromName(e.target.value)}
-                      />
-                    </div>
-                  </div>
-                </>
-              )}
-
-              {emailProvider === 'postmark' && (
-                <>
-                  <div className="space-y-2">
-                    <Label htmlFor="org-postmark-api-key">{t('email.serverToken')}</Label>
-                    <Input
-                      id="org-postmark-api-key"
-                      type="password"
-                      placeholder="••••••••-••••-••••-••••-••••••••••••"
-                      value={postmarkApiKey}
-                      onChange={(e) => setPostmarkApiKey(e.target.value)}
-                    />
-                    <p className="text-xs text-muted-foreground">
-                      {t('email.serverTokenHintPostmark')}{' '}
-                      <a
-                        href="https://postmarkapp.com"
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        className="underline"
-                      >
-                        postmarkapp.com
-                      </a>
-                    </p>
-                  </div>
-
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-postmark-from-email">{t('email.fromEmail')}</Label>
-                      <Input
-                        id="org-postmark-from-email"
-                        placeholder={t('email.fromEmailDomainPlaceholder')}
-                        value={postmarkFromEmail}
-                        onChange={(e) => setPostmarkFromEmail(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-postmark-from-name">{t('email.fromName')}</Label>
-                      <Input
-                        id="org-postmark-from-name"
-                        placeholder={t('email.fromNamePlaceholder')}
-                        value={postmarkFromName}
-                        onChange={(e) => setPostmarkFromName(e.target.value)}
-                      />
-                    </div>
-                  </div>
-                </>
-              )}
-
-              {emailProvider === 'mailgun' && (
-                <>
-                  <div className="space-y-2">
-                    <Label htmlFor="org-mailgun-api-key">{t('email.apiKey')}</Label>
-                    <Input
-                      id="org-mailgun-api-key"
-                      type="password"
-                      placeholder="key-••••••••••••••••••••••••••••••••"
-                      value={mailgunApiKey}
-                      onChange={(e) => setMailgunApiKey(e.target.value)}
-                    />
-                    <p className="text-xs text-muted-foreground">
-                      {t('email.apiKeyHintMailgun')}{' '}
-                      <a
-                        href="https://app.mailgun.com"
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        className="underline"
-                      >
-                        app.mailgun.com
-                      </a>
-                    </p>
-                  </div>
-
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-mailgun-domain">{t('email.domain')}</Label>
-                      <Input
-                        id="org-mailgun-domain"
-                        placeholder={t('email.domainPlaceholder')}
-                        value={mailgunDomain}
-                        onChange={(e) => setMailgunDomain(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-mailgun-region">{t('email.region')}</Label>
-                      <div className="flex gap-2">
-                        <Button
-                          type="button"
-                          variant={mailgunRegion === 'us' ? 'default' : 'outline'}
-                          onClick={() => setMailgunRegion('us')}
-                          className="flex-1"
-                          size="sm"
-                        >
-                          US
-                        </Button>
-                        <Button
-                          type="button"
-                          variant={mailgunRegion === 'eu' ? 'default' : 'outline'}
-                          onClick={() => setMailgunRegion('eu')}
-                          className="flex-1"
-                          size="sm"
-                        >
-                          EU
-                        </Button>
-                      </div>
-                    </div>
-                  </div>
-
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-mailgun-from-email">{t('email.fromEmail')}</Label>
-                      <Input
-                        id="org-mailgun-from-email"
-                        placeholder={t('email.fromEmailDomainPlaceholder')}
-                        value={mailgunFromEmail}
-                        onChange={(e) => setMailgunFromEmail(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-mailgun-from-name">{t('email.fromName')}</Label>
-                      <Input
-                        id="org-mailgun-from-name"
-                        placeholder={t('email.fromNamePlaceholder')}
-                        value={mailgunFromName}
-                        onChange={(e) => setMailgunFromName(e.target.value)}
-                      />
-                    </div>
-                  </div>
-                </>
-              )}
-
-              {emailProvider === 'sendgrid' && (
-                <>
-                  <div className="space-y-2">
-                    <Label htmlFor="org-sendgrid-api-key">{t('email.apiKey')}</Label>
-                    <Input
-                      id="org-sendgrid-api-key"
-                      type="password"
-                      placeholder="SG.••••••••••••••••••••••••••••••••"
-                      value={sendgridApiKey}
-                      onChange={(e) => setSendgridApiKey(e.target.value)}
-                    />
-                    <p className="text-xs text-muted-foreground">
-                      {t('email.apiKeyHintSendGrid')}{' '}
-                      <a
-                        href="https://app.sendgrid.com"
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        className="underline"
-                      >
-                        app.sendgrid.com
-                      </a>
-                    </p>
-                  </div>
-
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-sendgrid-from-email">{t('email.fromEmail')}</Label>
-                      <Input
-                        id="org-sendgrid-from-email"
-                        placeholder={t('email.fromEmailDomainPlaceholder')}
-                        value={sendgridFromEmail}
-                        onChange={(e) => setSendgridFromEmail(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-sendgrid-from-name">{t('email.fromName')}</Label>
-                      <Input
-                        id="org-sendgrid-from-name"
-                        placeholder={t('email.fromNamePlaceholder')}
-                        value={sendgridFromName}
-                        onChange={(e) => setSendgridFromName(e.target.value)}
-                      />
-                    </div>
-                  </div>
-                </>
-              )}
-
-              {emailProvider === 'ses' && (
-                <>
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-ses-access-key">{t('email.accessKeyId')}</Label>
-                      <Input
-                        id="org-ses-access-key"
-                        type="password"
-                        placeholder="AKIA••••••••••••••••"
-                        value={sesAccessKeyId}
-                        onChange={(e) => setSesAccessKeyId(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-ses-secret-key">{t('email.secretAccessKey')}</Label>
-                      <Input
-                        id="org-ses-secret-key"
-                        type="password"
-                        placeholder="••••••••••••••••••••••••••••••••••••••••"
-                        value={sesSecretAccessKey}
-                        onChange={(e) => setSesSecretAccessKey(e.target.value)}
-                      />
-                    </div>
-                  </div>
-
-                  <div className="space-y-2">
-                    <Label htmlFor="org-ses-region">{t('email.awsRegion')}</Label>
-                    <Input
-                      id="org-ses-region"
-                      placeholder={t('email.awsRegionPlaceholder')}
-                      value={sesRegion}
-                      onChange={(e) => setSesRegion(e.target.value)}
-                    />
-                    <p className="text-xs text-muted-foreground">{t('email.awsRegionHint')}</p>
-                  </div>
-
-                  <div className="grid gap-4 sm:grid-cols-2">
-                    <div className="space-y-2">
-                      <Label htmlFor="org-ses-from-email">{t('email.fromEmail')}</Label>
-                      <Input
-                        id="org-ses-from-email"
-                        placeholder={t('email.fromEmailDomainPlaceholder')}
-                        value={sesFromEmail}
-                        onChange={(e) => setSesFromEmail(e.target.value)}
-                      />
-                    </div>
-                    <div className="space-y-2">
-                      <Label htmlFor="org-ses-from-name">{t('email.fromName')}</Label>
-                      <Input
-                        id="org-ses-from-name"
-                        placeholder={t('email.fromNamePlaceholder')}
-                        value={sesFromName}
-                        onChange={(e) => setSesFromName(e.target.value)}
-                      />
-                    </div>
-                  </div>
-                </>
-              )}
-
-              <div className="flex items-center gap-2 pt-2">
-                <Button
-                  type="button"
-                  variant="outline"
-                  onClick={handleTestEmail}
-                  disabled={isTestDisabled}
-                >
-                  {isTesting ? (
-                    <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                  ) : (
-                    <Send className="mr-2 h-4 w-4" />
-                  )}
-                  {t('email.sendTestEmail')}
-                </Button>
-                <p className="text-xs text-muted-foreground">{t('email.testEmailHint')}</p>
-              </div>
-            </>
-          )}
-        </AppCard>
-
-        <SaveButton>
-          <div className="flex justify-end">
-            <Button onClick={handleSave} disabled={isPending}>
-              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {t('email.saveSettings')}
-            </Button>
-          </div>
-        </SaveButton>
-      </ReadOnlyWrapper>
-    </div>
-  )
-}

+ 206 - 36
src/features/integrations/Actions/integrationActions.ts

@@ -21,8 +21,88 @@ import {
 import { enqueueJob, runJob } from '../Lib/jobs'
 import { oauthSpec, platformClient, redirectUriFor } from '../Lib/oauth'
 import type { ConnectionStatus, ConnectorManifest, SettingOption } from '../Lib/types'
+import { messagingProvider } from '@/integrations/messaging/catalog'
+import {
+  type InboundWebhook,
+  completeMessagingCredentials,
+  inboundWebhook,
+  markChannelAdopted,
+  retireOtherProviders,
+} from '../Lib/messaging'
 import { openCredentials } from '../Lib/vault'
 
+/**
+ * The inbound URL for a messaging connection, from its sealed secret. A row
+ * whose credentials cannot be opened shows no URL rather than no page.
+ */
+function inboundFor(
+  row: { connectorId: string; organizationId: string; credentials: string | null },
+  appUrl: string
+): InboundWebhook | null {
+  if (!messagingProvider(row.connectorId)) return null
+  try {
+    return inboundWebhook(
+      row.connectorId,
+      row.organizationId,
+      openCredentials(row.credentials),
+      appUrl
+    )
+  } catch {
+    return null
+  }
+}
+
+const settingsSchema = z.record(
+  z.string(),
+  z.union([z.string().max(2000), z.number(), z.boolean()])
+)
+
+/** The settings the manifest declares, typed the way it declares them; the rest is dropped. */
+function cleanSettings(manifest: ConnectorManifest, raw: unknown): Record<string, unknown> {
+  const input = settingsSchema.parse(raw)
+  const clean: Record<string, unknown> = {}
+  for (const field of manifest.settings) {
+    if (!(field.key in input)) continue
+    const v = input[field.key]
+    if (field.type === 'boolean') clean[field.key] = Boolean(v)
+    else if (field.type === 'number') clean[field.key] = Number(v)
+    else clean[field.key] = String(v)
+  }
+  return clean
+}
+
+/**
+ * The one way a connection becomes live. A workshop sends through one SMS
+ * vendor, one mail vendor and so on, the way the old provider dropdown
+ * worked, so going live stands the channel's other vendors down, and records
+ * that the channel is decided by its connections from now on.
+ */
+async function activateConnection(
+  connectionId: string,
+  organizationId: string,
+  connectorId: string,
+  userId: string
+): Promise<void> {
+  await setConnectionStatus(connectionId, 'active')
+  const provider = messagingProvider(connectorId)
+  if (!provider) return
+  await retireOtherProviders(organizationId, connectorId)
+  await markChannelAdopted(organizationId, provider.channel, userId)
+}
+
+/** Settings a connector learned on its own, folded under what the workshop saved. */
+async function mergeSettings(connectionId: string, patch: Record<string, unknown>) {
+  const row = await db.integrationConnection.findUnique({
+    where: { id: connectionId },
+    select: { settings: true },
+  })
+  const settings = { ...((row?.settings as Record<string, unknown>) ?? {}), ...patch }
+  await db.integrationConnection.update({
+    where: { id: connectionId },
+    data: { settings: settings as object },
+  })
+}
+
 const SETTINGS_PERMISSION = [
   { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
 ]
@@ -104,6 +184,12 @@ export interface ConnectionView {
   enabled: boolean
   isCloud: boolean
   webhookUrl: string | null
+  /**
+   * Where a messaging vendor must deliver inbound messages, built from the
+   * connection's own secret. Null for vendors that register it themselves
+   * or have nothing inbound.
+   */
+  inbound: InboundWebhook | null
   /**
    * The callback the OAuth start route will send to the vendor. Computed on
    * the server from the configured app URL, so it is the same on the server
@@ -154,6 +240,7 @@ export async function getIntegrationConnection(connectorId: string) {
         isCloud: isCloudMode(),
         webhookUrl:
           row && appUrl ? `${appUrl}/api/integrations/${connectorId}/${row.id}/webhook` : null,
+        inbound: row ? inboundFor(row, configuredAppUrl()) : null,
         redirectUri: redirectUriFor(configuredAppUrl(), connectorId),
       }
     },
@@ -167,7 +254,11 @@ const credentialsSchema = z.record(z.string(), z.string().max(4000))
  * Store credentials the workshop typed in: API keys, client-credential
  * keys, or its own OAuth client id and secret ahead of the handshake.
  */
-export async function saveIntegrationCredentials(connectorId: string, raw: unknown) {
+export async function saveIntegrationCredentials(
+  connectorId: string,
+  raw: unknown,
+  rawSettings?: unknown
+) {
   return withAuth(
     async ({ organizationId, userId }) => {
       demoGuard()
@@ -176,7 +267,14 @@ export async function saveIntegrationCredentials(connectorId: string, raw: unkno
       const features = await getFeatures(organizationId)
       if (!features.integrations)
         throw new FeatureGatedError('integrations', 'Integrations are not included in your plan.')
+      // A channel keeps the plan gate it had as a settings page.
+      if (manifest.plan && !features[manifest.plan])
+        throw new FeatureGatedError(manifest.plan, `${manifest.name} is not included in your plan.`)
       const input = credentialsSchema.parse(raw)
+      // Settings typed on the connect page, saved before the key check runs:
+      // an SMTP port needs its TLS choice and a Mailgun key needs its region
+      // before either can be proved.
+      const settings = rawSettings === undefined ? {} : cleanSettings(manifest, rawSettings)
 
       const fields =
         manifest.auth.type === 'oauth2' ? (manifest.auth.tenantFields ?? []) : manifest.auth.fields
@@ -185,22 +283,38 @@ export async function saveIntegrationCredentials(connectorId: string, raw: unkno
       }
       const allowed = new Set(fields.map((f) => f.key))
       const clean: Record<string, string> = {}
-      for (const [k, v] of Object.entries(input))
-        if (allowed.has(k) && v.trim()) clean[k] = v.trim()
+      // An optional field sent back empty is being cleared, not left alone.
+      const cleared = new Set<string>()
+      for (const [k, v] of Object.entries(input)) {
+        if (!allowed.has(k)) continue
+        if (v.trim()) clean[k] = v.trim()
+        else cleared.add(k)
+      }
 
       const existing = await db.integrationConnection.findUnique({
         where: { organizationId_connectorId: { organizationId, connectorId } },
       })
       const previous = openCredentials(existing?.credentials)
-      // For OAuth the handshake still has to run; for keys the connection is live now.
-      const status = manifest.auth.type === 'oauth2' ? (existing?.status ?? 'pending') : 'active'
+      for (const k of cleared) delete previous[k]
+      // Nothing is live until the keys have been checked: the row keeps the
+      // status it had, and a brand-new one starts pending, so a send that
+      // lands in the middle of a connect neither picks up half-stored keys
+      // nor mints a secret over them.
       const connection = await db.integrationConnection.upsert({
         where: { organizationId_connectorId: { organizationId, connectorId } },
-        create: { organizationId, connectorId, status, createdById: userId },
-        update: { status },
+        create: { organizationId, connectorId, status: 'pending', createdById: userId },
+        update: {},
         select: { id: true },
       })
-      await storeCredentials(connection.id, { ...previous, ...clean })
+      // A messaging vendor also gets the secrets the workshop never types,
+      // such as the one its inbound webhook URL is built from, and the
+      // fingerprint that URL is later looked up by.
+      const completed = completeMessagingCredentials(connectorId, { ...previous, ...clean })
+      await storeCredentials(connection.id, completed.credentials)
+      const patch = { ...settings, ...completed.settings }
+      if (Object.keys(patch).length > 0) {
+        await mergeSettings(connection.id, patch)
+      }
 
       if (manifest.auth.type !== 'oauth2') {
         const { ctx, server } = await loadConnection(connection.id)
@@ -213,14 +327,35 @@ export async function saveIntegrationCredentials(connectorId: string, raw: unkno
           )
           throw new Error(result.message ?? 'Connection test failed')
         }
+        // Who the account is, when the vendor will say. Not being able to
+        // ask is no reason to refuse keys that just passed their check.
         if (server.identify) {
-          const who = await server.identify(ctx)
-          await db.integrationConnection.update({
-            where: { id: connection.id },
-            data: { externalAccountId: who.id, externalAccountName: who.name },
-          })
+          try {
+            const who = await server.identify(ctx)
+            await db.integrationConnection.update({
+              where: { id: connection.id },
+              data: { externalAccountId: who.id, externalAccountName: who.name },
+            })
+          } catch (err) {
+            await writeLog(connection.id, 'warn', 'Could not identify the account', {
+              error: err instanceof Error ? err.message : String(err),
+            })
+          }
+        }
+        // Remote setup, such as registering a webhook, happens before anything
+        // else is stood down, so a failure here leaves the previous vendor in
+        // charge rather than the workshop with no sender.
+        if (server.onConnect) {
+          try {
+            const outcome = await server.onConnect(ctx)
+            if (outcome?.settings) await mergeSettings(connection.id, outcome.settings)
+          } catch (err) {
+            const message = err instanceof Error ? err.message : 'Connection setup failed'
+            await setConnectionStatus(connection.id, 'error', message)
+            throw new Error(message)
+          }
         }
-        await setConnectionStatus(connection.id, 'active')
+        await activateConnection(connection.id, organizationId, connectorId, userId)
       }
       revalidatePath(`/settings/integrations/${connectorId}`)
       return { id: connection.id }
@@ -240,26 +375,13 @@ export async function saveIntegrationCredentials(connectorId: string, raw: unkno
   )
 }
 
-const settingsSchema = z.record(
-  z.string(),
-  z.union([z.string().max(2000), z.number(), z.boolean()])
-)
-
 export async function updateIntegrationSettings(connectorId: string, raw: unknown) {
   return withAuth(
     async ({ organizationId }) => {
       demoGuard()
       const manifest = getManifest(connectorId)
       if (!manifest) throw new Error('Unknown integration')
-      const input = settingsSchema.parse(raw)
-      const clean: Record<string, unknown> = {}
-      for (const field of manifest.settings) {
-        if (!(field.key in input)) continue
-        const v = input[field.key]
-        if (field.type === 'boolean') clean[field.key] = Boolean(v)
-        else if (field.type === 'number') clean[field.key] = Number(v)
-        else clean[field.key] = String(v)
-      }
+      const clean = cleanSettings(manifest, raw)
       const row = await db.integrationConnection.findUnique({
         where: { organizationId_connectorId: { organizationId, connectorId } },
         select: { id: true, settings: true, status: true },
@@ -297,20 +419,23 @@ export async function getIntegrationRemoteOptions(connectorId: string, source: s
 
 export async function testIntegration(connectorId: string) {
   return withAuth(
-    async ({ organizationId }) => {
+    async ({ organizationId, userId }) => {
+      demoGuard()
       const row = await db.integrationConnection.findUnique({
         where: { organizationId_connectorId: { organizationId, connectorId } },
-        select: { id: true },
+        select: { id: true, status: true },
       })
       if (!row) throw new Error('Connect the integration first')
+      // A vendor that was stood down or never finished connecting is not
+      // brought back by a passing check; that takes a deliberate connect.
+      if (row.status === 'pending' || row.status === 'disconnected') {
+        throw new Error('Connect the integration first')
+      }
       const { ctx, server } = await loadConnection(row.id)
       try {
         const result = await server.test(ctx)
-        await setConnectionStatus(
-          row.id,
-          result.ok ? 'active' : 'error',
-          result.ok ? null : (result.message ?? 'Test failed')
-        )
+        if (result.ok) await activateConnection(row.id, organizationId, connectorId, userId)
+        else await setConnectionStatus(row.id, 'error', result.message ?? 'Test failed')
         await writeLog(
           row.id,
           result.ok ? 'info' : 'error',
@@ -331,6 +456,46 @@ export async function testIntegration(connectorId: string) {
 }
 
 /** Queue one of the connector's jobs now, for example a full calendar pull. */
+/**
+ * Send a real message to the signed-in user through one connection. A key
+ * check proves the key; a delivered email proves the from address and the
+ * vendor's sending rules, which is what the old email page's test button did.
+ */
+export async function sendIntegrationTestMessage(connectorId: string) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      demoGuard()
+      const row = await db.integrationConnection.findUnique({
+        where: { organizationId_connectorId: { organizationId, connectorId } },
+        select: { id: true, status: true },
+      })
+      if (!row) throw new Error('Connect the integration first')
+      if (row.status === 'pending' || row.status === 'disconnected') {
+        throw new Error('Connect the integration first')
+      }
+      const user = await db.user.findUnique({ where: { id: userId }, select: { email: true } })
+      if (!user?.email) throw new Error('Could not find your email address')
+
+      const { ctx, server } = await loadConnection(row.id)
+      if (!server.sendTest) throw new Error('This integration cannot send a test message')
+      try {
+        await server.sendTest(ctx, { email: user.email })
+      } catch (err) {
+        const message = err instanceof Error ? err.message : 'Sending failed'
+        await writeLog(row.id, 'error', `Test message failed: ${message}`)
+        throw new Error(message)
+      }
+      await writeLog(row.id, 'info', `Test message sent to ${user.email}`)
+      // A delivered message is the strongest check there is, so a vendor in
+      // error comes back live through the same door as any other.
+      await activateConnection(row.id, organizationId, connectorId, userId)
+      revalidatePath(`/settings/integrations/${connectorId}`)
+      return { sentTo: user.email }
+    },
+    { requiredPermissions: SETTINGS_PERMISSION }
+  )
+}
+
 export async function runIntegrationJob(connectorId: string, kind: string) {
   return withAuth(
     async ({ organizationId }) => {
@@ -394,7 +559,7 @@ export async function backfillIntegrationCalendar(connectorId: string) {
 
 export async function disconnectIntegration(connectorId: string) {
   return withAuth(
-    async ({ organizationId }) => {
+    async ({ organizationId, userId }) => {
       demoGuard()
       const row = await db.integrationConnection.findUnique({
         where: { organizationId_connectorId: { organizationId, connectorId } },
@@ -409,6 +574,11 @@ export async function disconnectIntegration(connectorId: string) {
           console.warn('[integrations] onDisconnect failed:', err)
         }
       }
+      // A messaging vendor the workshop disconnects stays disconnected: the
+      // rows it was set up from before the move must not be adopted back on
+      // the next send.
+      const provider = messagingProvider(connectorId)
+      if (provider) await markChannelAdopted(organizationId, provider.channel, userId)
       // Tokens go; links and logs go with the row so nothing dangles.
       await db.integrationConnection.delete({ where: { id: row.id } })
       await clearPulledEvents(row.id)

+ 609 - 0
src/features/integrations/Lib/messaging.ts

@@ -0,0 +1,609 @@
+/**
+ * Which connection a channel sends through.
+ *
+ * SMS, WhatsApp, Telegram and email moved into the integrations catalog, so
+ * their keys now live sealed on an `IntegrationConnection` like every other
+ * vendor's. What must not change is that a workshop which set Twilio up years
+ * ago keeps sending: the first time a channel is used after the move, an
+ * existing setup in `AppSetting` is adopted into a connection, sealed, and
+ * used from then on. Nothing is asked of the workshop and nothing is deleted,
+ * so a rollback still finds the old rows where it left them.
+ *
+ * Callers get their values back under the old setting keys. That keeps the
+ * change at the edge of the send paths rather than through them.
+ */
+
+import { createHash, randomBytes } from 'node:crypto'
+import {
+  type MessagingChannel,
+  type MessagingProvider,
+  legacyKeysForChannel,
+  messagingProvider,
+  providerForLegacyId,
+  providersForChannel,
+} from '@/integrations/messaging/catalog'
+import { db } from '@/lib/db'
+import { openCredentials, sealCredentials } from './vault'
+
+export type { MessagingChannel }
+
+/** Row that names the vendor a channel was pointed at before the move. */
+const LEGACY_PROVIDER_KEY: Record<MessagingChannel, string | null> = {
+  sms: 'sms.provider',
+  whatsapp: 'whatsapp.provider',
+  email: 'email.provider',
+  telegram: null,
+}
+
+export interface ChannelSetup {
+  connectionId: string
+  connectorId: string
+  provider: MessagingProvider
+  credentials: Record<string, string>
+  settings: Record<string, unknown>
+}
+
+function stringify(value: unknown): string {
+  if (value === null || value === undefined) return ''
+  if (typeof value === 'boolean') return value ? 'true' : 'false'
+  return String(value)
+}
+
+/**
+ * The channel's values under the keys the send paths already read, whichever
+ * side of the move they came from.
+ */
+export function asLegacyMap(setup: ChannelSetup): Map<string, string> {
+  const map = new Map<string, string>()
+  for (const field of setup.provider.credentials) {
+    if (!field.legacy) continue
+    const value = setup.credentials[field.key]
+    if (value) map.set(field.legacy, value)
+  }
+  for (const field of setup.provider.settings) {
+    if (!field.legacy) continue
+    const value = stringify(setup.settings[field.key])
+    if (value) map.set(field.legacy, value)
+  }
+  const providerKey = LEGACY_PROVIDER_KEY[setup.provider.channel]
+  if (providerKey && setup.provider.legacyProvider) {
+    map.set(providerKey, setup.provider.legacyProvider)
+  }
+  return map
+}
+
+async function readLegacy(
+  organizationId: string,
+  channel: MessagingChannel
+): Promise<{ values: Map<string, string>; userId: string | null; adopted: boolean }> {
+  const keys = legacyKeysForChannel(channel)
+  const providerKey = LEGACY_PROVIDER_KEY[channel]
+  if (providerKey) keys.push(providerKey)
+  keys.push(adoptedMarkerKey(channel))
+
+  const rows = await db.appSetting.findMany({
+    where: { organizationId, key: { in: keys } },
+    select: { key: true, value: true, userId: true },
+  })
+  const values = new Map(rows.map((r) => [r.key, r.value]))
+  return {
+    values,
+    userId: rows.find((r) => r.userId)?.userId ?? null,
+    adopted: values.has(adoptedMarkerKey(channel)),
+  }
+}
+
+/**
+ * The vendor an old setup was using.
+ *
+ * The `<channel>.provider` row decides it, exactly as the old send paths did:
+ * a workshop that pasted a Resend key and then switched back to the platform
+ * default cleared that row, and it must not start sending through Resend now.
+ * Telegram never had a provider row, so the bot token being set is the whole
+ * signal there. Either way the vendor's own evidence key has to be filled in,
+ * so an SMS setup abandoned halfway is left alone rather than adopted into a
+ * connection that cannot send.
+ */
+function legacyProviderFor(
+  channel: MessagingChannel,
+  values: Map<string, string>
+): MessagingProvider | null {
+  const providerKey = LEGACY_PROVIDER_KEY[channel]
+  const named = providerKey ? (values.get(providerKey) ?? null) : null
+  if (providerKey && !named) return null
+  const chosen = providerForLegacyId(channel, named)
+  if (chosen && values.get(chosen.legacyEvidence)?.trim()) return chosen
+  return null
+}
+
+/** Manifest defaults under what the workshop saved, the way the settings form shows them. */
+function withDefaults(
+  provider: MessagingProvider,
+  saved: Record<string, unknown>
+): Record<string, unknown> {
+  const out: Record<string, unknown> = {}
+  for (const field of provider.settings) {
+    if (field.default !== undefined) out[field.key] = field.default
+  }
+  return { ...out, ...saved }
+}
+
+/** What a channel sends as: a from address, a number or a bot name. */
+function sendingIdentity(settings: Record<string, unknown>): string | undefined {
+  for (const key of ['fromEmail', 'phoneNumber', 'botUsername']) {
+    const value = settings[key]
+    if (typeof value === 'string' && value.trim()) {
+      return key === 'botUsername' ? `@${value.trim()}` : value.trim()
+    }
+  }
+  return undefined
+}
+
+/**
+ * Row that records a channel having been adopted, so it happens once.
+ *
+ * Without it, disconnecting an adopted vendor would be undone by the next
+ * send, which would read the old rows and adopt them all over again. The row
+ * is one more setting the old code never reads, so a rollback ignores it.
+ */
+export function adoptedMarkerKey(channel: MessagingChannel): string {
+  return `integrations.${channel}.adoptedAt`
+}
+
+/**
+ * Record that the channel's truth is the connections table from now on.
+ *
+ * Written when an old setup is adopted, and also when a workshop connects a
+ * vendor through the catalog: either way, a later disconnect must stick
+ * rather than be undone by the old rows on the next send.
+ */
+export async function markChannelAdopted(
+  organizationId: string,
+  channel: MessagingChannel,
+  userId: string
+): Promise<void> {
+  const key = adoptedMarkerKey(channel)
+  await db.appSetting.upsert({
+    where: { organizationId_key: { organizationId, key } },
+    create: { organizationId, userId, key, value: new Date().toISOString() },
+    update: {},
+  })
+}
+
+function splitLegacy(
+  provider: MessagingProvider,
+  values: Map<string, string>
+): { credentials: Record<string, string>; settings: Record<string, unknown> } {
+  const credentials: Record<string, string> = {}
+  for (const field of provider.credentials) {
+    const value = field.legacy ? values.get(field.legacy) : undefined
+    // A row the old form never wrote, such as an SMTP port, takes the value
+    // the old send path assumed for it.
+    if (value?.trim()) credentials[field.key] = value
+    else if (field.default) credentials[field.key] = field.default
+  }
+  const settings: Record<string, unknown> = {}
+  for (const field of provider.settings) {
+    const raw = field.legacy ? values.get(field.legacy) : undefined
+    if (raw === undefined) continue
+    // An empty boolean row is no answer; the catalog default applies, the
+    // way the old send path treated a missing row.
+    if (field.type === 'boolean' && raw === '') continue
+    settings[field.key] = field.type === 'boolean' ? raw === 'true' : raw
+  }
+  return { credentials, settings }
+}
+
+/**
+ * Fingerprint of a webhook secret, kept unsealed alongside the connection.
+ *
+ * Vonage's inbound URL identifies the workshop by the secret in its query
+ * string, which means looking an organization up from the secret alone.
+ * Sealed credentials cannot be searched — every row has its own nonce — so
+ * the lookup goes through this hash instead, and the secret itself stays
+ * sealed.
+ */
+export const WEBHOOK_SECRET_HASH = 'webhookSecretHash'
+
+export function webhookSecretHash(secret: string): string {
+  return createHash('sha256').update(secret).digest('hex')
+}
+
+/** Secrets the workshop never types, minted once and kept for good. */
+function withGeneratedSecrets(
+  provider: MessagingProvider,
+  credentials: Record<string, string>
+): { credentials: Record<string, string>; added: boolean } {
+  let added = false
+  const next = { ...credentials }
+  for (const field of provider.credentials) {
+    if (!field.generated || next[field.key]?.trim()) continue
+    next[field.key] = randomBytes(24).toString('hex')
+    added = true
+  }
+  return { credentials: next, added }
+}
+
+/**
+ * One vendor per channel stays in charge, the way the old provider dropdown
+ * worked. Connecting a second SMS vendor retires the first rather than
+ * leaving the app to guess which one a message should go out through.
+ */
+export async function retireOtherProviders(
+  organizationId: string,
+  connectorId: string
+): Promise<void> {
+  const provider = messagingProvider(connectorId)
+  if (!provider) return
+  const siblings = providersForChannel(provider.channel)
+    .map((p) => p.id)
+    .filter((id) => id !== connectorId)
+  if (siblings.length === 0) return
+
+  await db.integrationConnection.updateMany({
+    where: { organizationId, connectorId: { in: siblings }, status: 'active' },
+    data: { status: 'disconnected', lastError: null },
+  })
+}
+
+export interface LegacySetup {
+  provider: MessagingProvider
+  credentials: Record<string, string>
+  settings: Record<string, unknown>
+  userId: string | null
+}
+
+/**
+ * The setup an organization had before the move, read without writing
+ * anything: the vendor its provider row names, the keys under that vendor's
+ * rows, and nothing if the setup was never finished.
+ */
+export async function legacySetupFor(
+  organizationId: string,
+  channel: MessagingChannel
+): Promise<{ setup: LegacySetup | null; adopted: boolean }> {
+  const { values, userId, adopted } = await readLegacy(organizationId, channel)
+  const provider = legacyProviderFor(channel, values)
+  if (!provider) return { setup: null, adopted }
+
+  const split = splitLegacy(provider, values)
+  const required = provider.credentials.filter((f) => f.required && !f.generated)
+  if (required.some((f) => !split.credentials[f.key])) return { setup: null, adopted }
+
+  return { setup: { provider, ...split, userId }, adopted }
+}
+
+/**
+ * Whether the old rows name a vendor for the channel, adopted or not. Lets a
+ * send path fail the way it used to ("Mailgun is not configured") instead of
+ * quietly doing something else when a named vendor's setup is incomplete.
+ */
+export async function legacyProviderNamed(
+  organizationId: string,
+  channel: MessagingChannel
+): Promise<string | null> {
+  const key = LEGACY_PROVIDER_KEY[channel]
+  if (!key) return null
+  const row = await db.appSetting.findUnique({
+    where: { organizationId_key: { organizationId, key } },
+    select: { value: true },
+  })
+  const named = row?.value?.trim()
+  return named && providerForLegacyId(channel, named) ? named : null
+}
+
+/** Prisma's code for a unique constraint the row already satisfies. */
+function isUniqueViolation(err: unknown): boolean {
+  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002'
+}
+
+async function adoptLegacySetup(
+  organizationId: string,
+  channel: MessagingChannel
+): Promise<ChannelSetup | null> {
+  const { setup, adopted } = await legacySetupFor(organizationId, channel)
+  // Adopted once already: whatever happened to that connection since, such as
+  // the workshop disconnecting it, is the current truth.
+  if (adopted || !setup) return null
+  const { provider, userId } = setup
+
+  // A row for this vendor that is not active was put there by the workshop,
+  // for instance keys that failed their check. It stays theirs to fix; the
+  // old rows are neither copied over it nor marked as adopted.
+  const existing = await db.integrationConnection.findUnique({
+    where: { organizationId_connectorId: { organizationId, connectorId: provider.id } },
+    select: { id: true },
+  })
+  if (existing) return null
+
+  const { credentials } = withGeneratedSecrets(provider, setup.credentials)
+  const settings = { ...setup.settings }
+  if (credentials.webhookSecret) {
+    settings[WEBHOOK_SECRET_HASH] = webhookSecretHash(credentials.webhookSecret)
+  }
+
+  // Two sends can land here at once on the first message after a deploy. The
+  // loser of that race finds the winner's row and uses it.
+  let connection: { id: string; credentials: string | null; settings: unknown; status: string }
+  try {
+    connection = await db.integrationConnection.create({
+      data: {
+        organizationId,
+        connectorId: provider.id,
+        status: 'active',
+        credentials: sealCredentials(credentials),
+        settings: settings as object,
+        createdById: userId ?? 'migration',
+        label: 'Adopted from settings',
+        // The connection page shows this as the connected account. An adopted
+        // setup never went through the vendor's identify call, so the address
+        // or number it sends from is the nearest thing to a name.
+        externalAccountName: sendingIdentity(settings) ?? null,
+      },
+      select: { id: true, credentials: true, settings: true, status: true },
+    })
+  } catch (err) {
+    if (!isUniqueViolation(err)) throw err
+    const winner = await db.integrationConnection.findUnique({
+      where: { organizationId_connectorId: { organizationId, connectorId: provider.id } },
+      select: { id: true, credentials: true, settings: true, status: true },
+    })
+    if (!winner) throw err
+    connection = winner
+  }
+
+  // Every old row carries the user who wrote it, so there is always one to
+  // own the marker.
+  if (userId) await markChannelAdopted(organizationId, channel, userId)
+
+  if (connection.status !== 'active') return null
+
+  return {
+    connectionId: connection.id,
+    connectorId: provider.id,
+    provider,
+    credentials: openCredentials(connection.credentials) as Record<string, string>,
+    settings: withDefaults(provider, (connection.settings as Record<string, unknown>) ?? {}),
+  }
+}
+
+/**
+ * Last resort when a connection's credentials cannot be opened: the old rows
+ * are still there, so the channel keeps working from them while someone
+ * sorts the key out. Logged every time, because it should never be quiet.
+ */
+async function unsealedFallback(
+  organizationId: string,
+  channel: MessagingChannel,
+  connectionId: string,
+  err: unknown
+): Promise<ChannelSetup | null> {
+  console.error(
+    `[integrations] cannot open credentials for ${channel} connection ${connectionId} of organization ${organizationId}; ` +
+      'check INTEGRATIONS_ENCRYPTION_KEY / BETTER_AUTH_SECRET (see scripts/rekey-integrations.ts). ' +
+      'Falling back to the settings rows from before the move.',
+    err
+  )
+  const { setup } = await legacySetupFor(organizationId, channel)
+  if (!setup) return null
+  return {
+    connectionId,
+    connectorId: setup.provider.id,
+    provider: setup.provider,
+    credentials: setup.credentials,
+    settings: withDefaults(setup.provider, setup.settings),
+  }
+}
+
+/**
+ * The connection a channel sends through, adopting an old setup on first use.
+ *
+ * Returns null when the workshop has never configured the channel, which is
+ * the caller's cue to raise its own "not configured" message.
+ */
+export async function channelSetup(
+  organizationId: string,
+  channel: MessagingChannel
+): Promise<ChannelSetup | null> {
+  const ids = providersForChannel(channel).map((p) => p.id)
+  const rows = await db.integrationConnection.findMany({
+    where: { organizationId, connectorId: { in: ids }, status: 'active' },
+    orderBy: { updatedAt: 'desc' },
+    select: { id: true, connectorId: true, credentials: true, settings: true },
+  })
+
+  const row = rows[0]
+  if (!row) return adoptLegacySetup(organizationId, channel)
+
+  const provider = messagingProvider(row.connectorId)
+  if (!provider) return null
+
+  let stored: Record<string, string>
+  try {
+    stored = openCredentials(row.credentials) as Record<string, string>
+  } catch (err) {
+    return unsealedFallback(organizationId, channel, row.id, err)
+  }
+  const { credentials, added } = withGeneratedSecrets(provider, stored)
+  const settings = (row.settings as Record<string, unknown>) ?? {}
+  const hash = credentials.webhookSecret ? webhookSecretHash(credentials.webhookSecret) : null
+  const staleHash = hash !== null && settings[WEBHOOK_SECRET_HASH] !== hash
+
+  if (added || staleHash) {
+    if (hash) settings[WEBHOOK_SECRET_HASH] = hash
+    await db.integrationConnection.update({
+      where: { id: row.id },
+      data: {
+        ...(added ? { credentials: sealCredentials(credentials) } : {}),
+        ...(staleHash ? { settings: settings as object } : {}),
+      },
+    })
+  }
+
+  return {
+    connectionId: row.id,
+    connectorId: row.connectorId,
+    provider,
+    credentials,
+    settings: withDefaults(provider, settings),
+  }
+}
+
+/**
+ * Whether a channel is switched on, for the screens that offer it.
+ *
+ * WhatsApp and Telegram keep the on/off switch their old pages had, so a
+ * workshop can leave its keys in place and still take a channel off the
+ * customer page for a while. A channel with no connection is off.
+ */
+export async function channelEnabled(
+  organizationId: string,
+  channel: MessagingChannel
+): Promise<boolean> {
+  const setup = await channelSetup(organizationId, channel)
+  if (!setup) return false
+  return setup.settings.enabled !== false
+}
+
+/**
+ * Credentials the way a messaging connection stores them: the typed keys plus
+ * the secrets the platform mints, and the fingerprint the inbound webhook
+ * routes look a workshop up by. Called when keys are saved from the form, so
+ * a vendor connected today gets the same shape as one adopted from old rows.
+ */
+export function completeMessagingCredentials(
+  connectorId: string,
+  credentials: Record<string, unknown>
+): { credentials: Record<string, unknown>; settings: Record<string, unknown> } {
+  const provider = messagingProvider(connectorId)
+  if (!provider) return { credentials, settings: {} }
+  const typed = credentials as Record<string, string>
+  const next = withGeneratedSecrets(provider, typed).credentials
+  const settings: Record<string, unknown> = {}
+  if (next.webhookSecret) settings[WEBHOOK_SECRET_HASH] = webhookSecretHash(next.webhookSecret)
+  return { credentials: { ...credentials, ...next }, settings }
+}
+
+/**
+ * A channel's configuration under the old setting keys.
+ *
+ * Send paths were written against a map of `AppSetting` rows, and this keeps
+ * that shape so the move did not have to reach into every provider call.
+ */
+export async function channelSettings(
+  organizationId: string,
+  channel: MessagingChannel
+): Promise<Map<string, string>> {
+  const setup = await channelSetup(organizationId, channel)
+  return setup ? asLegacyMap(setup) : new Map()
+}
+
+/** The vendor a channel is pointed at, in the old `<channel>.provider` wording. */
+export async function channelProvider(
+  organizationId: string,
+  channel: MessagingChannel
+): Promise<string | null> {
+  const setup = await channelSetup(organizationId, channel)
+  return setup?.provider.legacyProvider ?? (setup ? setup.connectorId : null)
+}
+
+/**
+ * The workshop an inbound webhook belongs to, from the secret in its URL.
+ *
+ * Checks the connections first and the row the secret used to live in second,
+ * so a URL a vendor was given years ago keeps resolving whether or not the
+ * setup has been adopted yet.
+ */
+export async function organizationForWebhookSecret(
+  channel: MessagingChannel,
+  secret: string,
+  legacyKey: string
+): Promise<string | null> {
+  if (!secret.trim()) return null
+
+  const ids = providersForChannel(channel).map((p) => p.id)
+  const connection = await db.integrationConnection.findFirst({
+    where: {
+      connectorId: { in: ids },
+      status: 'active',
+      settings: { path: [WEBHOOK_SECRET_HASH], equals: webhookSecretHash(secret) },
+    },
+    select: { organizationId: true },
+  })
+  if (connection) return connection.organizationId
+
+  const row = await db.appSetting.findFirst({
+    where: { key: legacyKey, value: secret },
+    select: { organizationId: true },
+  })
+  if (!row?.organizationId) return null
+
+  // The old row only answers for a workshop that is still on the old side of
+  // the move. Once the channel has been adopted or connected through the
+  // catalog, the connections decide, and a vendor that was disconnected or
+  // retired must not keep delivering through the URL it was given years ago.
+  const [marker, any] = await Promise.all([
+    db.appSetting.findUnique({
+      where: {
+        organizationId_key: {
+          organizationId: row.organizationId,
+          key: adoptedMarkerKey(channel),
+        },
+      },
+      select: { id: true },
+    }),
+    db.integrationConnection.findFirst({
+      where: { organizationId: row.organizationId, connectorId: { in: ids } },
+      select: { id: true },
+    }),
+  ])
+  if (marker || any) return null
+  return row.organizationId
+}
+
+export interface InboundWebhook {
+  url: string
+  /** What the vendor also needs beside the URL, as an i18n key under connection. */
+  note: 'inboundHintSecret' | 'inboundHintMeta'
+}
+
+/**
+ * The URL a vendor must call for inbound messages on this connection.
+ *
+ * SMS vendors and WhatsApp via Twilio identify the workshop by the secret in
+ * the URL, which is why it is built from the sealed credentials here rather
+ * than typed anywhere. Meta signs its calls and verifies the URL with the
+ * verify token the workshop chose. Telegram registers its own URL on connect,
+ * so it has nothing to show.
+ */
+export function inboundWebhook(
+  connectorId: string,
+  organizationId: string,
+  credentials: Record<string, unknown>,
+  appUrl: string
+): InboundWebhook | null {
+  const provider = messagingProvider(connectorId)
+  if (!provider || !appUrl) return null
+  const base = appUrl.replace(/\/$/, '')
+  const secret = typeof credentials.webhookSecret === 'string' ? credentials.webhookSecret : ''
+  const token = typeof credentials.webhookToken === 'string' ? credentials.webhookToken : ''
+
+  if (provider.channel === 'sms') {
+    if (!secret || !provider.legacyProvider) return null
+    return {
+      url: `${base}/api/webhooks/sms/${provider.legacyProvider}?org_secret=${secret}`,
+      note: 'inboundHintSecret',
+    }
+  }
+  if (provider.id === 'whatsapp-twilio') {
+    if (!token) return null
+    return {
+      url: `${base}/api/webhooks/whatsapp/twilio/${organizationId}?token=${token}`,
+      note: 'inboundHintSecret',
+    }
+  }
+  if (provider.id === 'whatsapp-meta') {
+    return { url: `${base}/api/webhooks/whatsapp/meta/${organizationId}`, note: 'inboundHintMeta' }
+  }
+  return null
+}

+ 14 - 0
src/features/integrations/Lib/types.ts

@@ -31,6 +31,8 @@ export interface CredentialField {
   type: 'text' | 'password' | 'url'
   required?: boolean
   placeholder?: string
+  /** Prefilled on the connect page, for values with one usual answer such as a port. */
+  default?: string
   /** i18n key with guidance on where the value comes from */
   help?: string
 }
@@ -189,6 +191,12 @@ export interface ConnectorServer {
   identify?(ctx: ConnectorContext): Promise<{ id: string; name: string }>
   /** Cheap round trip that proves the credentials still work. */
   test(ctx: ConnectorContext): Promise<{ ok: boolean; message?: string }>
+  /**
+   * Send a real message to the signed-in user, for vendors where a key check
+   * is not proof that mail or texts actually go out. Throws with the vendor's
+   * reason when it fails.
+   */
+  sendTest?(ctx: ConnectorContext, to: { email: string }): Promise<void>
   /** Providers for remote-select settings, keyed by SettingField.source. */
   remoteOptions?: Record<string, (ctx: ConnectorContext) => Promise<SettingOption[]>>
   jobs: Record<string, JobHandler>
@@ -199,6 +207,12 @@ export interface ConnectorServer {
       ctx: ConnectorContext
     ): Promise<{ jobs: { kind: string; payload?: Record<string, unknown> }[]; response?: Response }>
   }
+  /**
+   * Set up remote state once the credentials have proved good, such as
+   * registering a webhook with the vendor. Settings returned here are saved
+   * on the connection, for values the vendor knows and the form did not ask.
+   */
+  onConnect?(ctx: ConnectorContext): Promise<{ settings?: Record<string, unknown> } | void>
   /** Clean up remote state when the workshop disconnects. */
   onDisconnect?(ctx: ConnectorContext): Promise<void>
 }

+ 0 - 95
src/features/sms/Actions/smsSettingsActions.ts

@@ -1,95 +0,0 @@
-'use server'
-
-import { randomBytes } from 'crypto'
-import { db } from '@/lib/db'
-import { withAuth } from '@/lib/with-auth'
-import { revalidatePath } from 'next/cache'
-import { ALL_ORG_SMS_KEYS, ORG_SMS_KEYS } from '../Schema/smsSettingsSchema'
-import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { sendOrgSms } from '@/lib/sms'
-import { demoGuard } from '@/lib/demo'
-
-export async function getSmsSettings() {
-  return withAuth(
-    async ({ organizationId }) => {
-      const settings = await db.appSetting.findMany({
-        where: { organizationId, key: { in: ALL_ORG_SMS_KEYS } },
-      })
-      const map: Record<string, string> = {}
-      for (const s of settings) {
-        map[s.key] = s.value
-      }
-      return map
-    },
-    {
-      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
-    }
-  )
-}
-
-export async function setSmsSettings(entries: Record<string, string>) {
-  return withAuth(
-    async ({ userId, organizationId }) => {
-      demoGuard()
-      // Auto-generate webhook secret if not already set
-      const existing = await db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId,
-            key: ORG_SMS_KEYS.SMS_WEBHOOK_SECRET,
-          },
-        },
-      })
-
-      if (!existing?.value) {
-        entries[ORG_SMS_KEYS.SMS_WEBHOOK_SECRET] = randomBytes(24).toString('hex')
-      }
-
-      await db.$transaction(
-        Object.entries(entries).map(([key, value]) =>
-          db.appSetting.upsert({
-            where: { organizationId_key: { organizationId, key } },
-            update: { value },
-            create: { userId, organizationId, key, value },
-          })
-        )
-      )
-      revalidatePath('/settings/sms')
-      return true
-    },
-    {
-      requiredPermissions: [
-        {
-          action: PermissionAction.UPDATE,
-          subject: PermissionSubject.SETTINGS,
-        },
-      ],
-    }
-  )
-}
-
-export async function testSmsSend(testPhone: string) {
-  return withAuth(
-    async ({ organizationId }) => {
-      demoGuard()
-      if (!testPhone?.trim()) {
-        throw new Error('Please enter a phone number to send the test SMS to')
-      }
-
-      const result = await sendOrgSms(organizationId, {
-        to: testPhone.trim(),
-        body: 'SMS test from Torqvoice — your SMS provider is configured correctly.',
-      })
-
-      return { sentTo: result.to }
-    },
-    {
-      requiredPermissions: [
-        {
-          action: PermissionAction.UPDATE,
-          subject: PermissionSubject.SETTINGS,
-        },
-      ],
-    }
-  )
-}

+ 0 - 340
src/features/sms/Components/SmsSettingsForm.tsx

@@ -1,340 +0,0 @@
-'use client'
-
-import { useState, useTransition } from 'react'
-import { useTranslations } from 'next-intl'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
-import { AppCard } from '@/components/app-card'
-import { Loader2, Send, Info, Copy, Check } from 'lucide-react'
-import { ORG_SMS_KEYS } from '../Schema/smsSettingsSchema'
-import { setSmsSettings, testSmsSend } from '../Actions/smsSettingsActions'
-import {
-  ReadOnlyBanner,
-  SaveButton,
-  ReadOnlyWrapper,
-} from '@/app/(authenticated)/settings/read-only-guard'
-
-type SmsProviderType = 'twilio' | 'vonage' | 'telnyx'
-
-export function SmsSettingsForm({
-  initial,
-  appUrl,
-}: {
-  initial: Record<string, string>
-  appUrl: string
-}) {
-  const t = useTranslations('settings')
-  const router = useRouter()
-  const [isPending, startTransition] = useTransition()
-  const [isTesting, setIsTesting] = useState(false)
-  const [copied, setCopied] = useState(false)
-  const [testPhone, setTestPhone] = useState('')
-
-  const hasProvider = !!initial[ORG_SMS_KEYS.SMS_PROVIDER]
-  const [enabled, setEnabled] = useState(hasProvider)
-
-  const [smsProvider, setSmsProvider] = useState<SmsProviderType>(
-    (initial[ORG_SMS_KEYS.SMS_PROVIDER] as SmsProviderType) || 'twilio'
-  )
-
-  // Shared
-  const [phoneNumber, setPhoneNumber] = useState(initial[ORG_SMS_KEYS.SMS_PHONE_NUMBER] || '')
-
-  // Twilio
-  const [twilioAccountSid, setTwilioAccountSid] = useState(
-    initial[ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID] || ''
-  )
-  const [twilioAuthToken, setTwilioAuthToken] = useState(
-    initial[ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN] || ''
-  )
-
-  // Vonage
-  const [vonageApiKey, setVonageApiKey] = useState(initial[ORG_SMS_KEYS.SMS_VONAGE_API_KEY] || '')
-  const [vonageApiSecret, setVonageApiSecret] = useState(
-    initial[ORG_SMS_KEYS.SMS_VONAGE_API_SECRET] || ''
-  )
-
-  // Telnyx
-  const [telnyxApiKey, setTelnyxApiKey] = useState(initial[ORG_SMS_KEYS.SMS_TELNYX_API_KEY] || '')
-
-  const webhookSecret = initial[ORG_SMS_KEYS.SMS_WEBHOOK_SECRET] || ''
-  const webhookUrl = webhookSecret
-    ? `${appUrl}/api/webhooks/sms/${smsProvider}?org_secret=${webhookSecret}`
-    : ''
-
-  const handleCopyWebhook = () => {
-    if (!webhookUrl) return
-    navigator.clipboard.writeText(webhookUrl)
-    setCopied(true)
-    toast.success(t('sms.webhookCopied'))
-    setTimeout(() => setCopied(false), 2000)
-  }
-
-  const handleSave = () => {
-    startTransition(async () => {
-      if (!enabled) {
-        const result = await setSmsSettings({
-          [ORG_SMS_KEYS.SMS_PROVIDER]: '',
-        })
-        if (result.success) {
-          toast.success(t('sms.savedDisabled'))
-          router.refresh()
-        } else {
-          toast.error(result.error ?? t('sms.failedSave'))
-        }
-        return
-      }
-
-      const data: Record<string, string> = {
-        [ORG_SMS_KEYS.SMS_PROVIDER]: smsProvider,
-        [ORG_SMS_KEYS.SMS_PHONE_NUMBER]: phoneNumber,
-        // Twilio
-        [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID]: twilioAccountSid,
-        [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN]: twilioAuthToken,
-        // Vonage
-        [ORG_SMS_KEYS.SMS_VONAGE_API_KEY]: vonageApiKey,
-        [ORG_SMS_KEYS.SMS_VONAGE_API_SECRET]: vonageApiSecret,
-        // Telnyx
-        [ORG_SMS_KEYS.SMS_TELNYX_API_KEY]: telnyxApiKey,
-      }
-
-      const result = await setSmsSettings(data)
-      if (result.success) {
-        toast.success(t('sms.saved'))
-        router.refresh()
-      } else {
-        toast.error(result.error ?? t('sms.failedSave'))
-      }
-    })
-  }
-
-  const handleTestSms = async () => {
-    if (!testPhone.trim()) {
-      toast.error(t('sms.enterTestPhone'))
-      return
-    }
-    setIsTesting(true)
-    try {
-      const result = await testSmsSend(testPhone.trim())
-      if (result.success) {
-        toast.success(t('sms.testSentTo', { phone: result.data?.sentTo ?? '' }))
-      } else {
-        toast.error(result.error ?? t('sms.testFailed'))
-      }
-    } finally {
-      setIsTesting(false)
-    }
-  }
-
-  const isTestDisabled =
-    isTesting ||
-    !enabled ||
-    !phoneNumber ||
-    !testPhone.trim() ||
-    (smsProvider === 'twilio' && (!twilioAccountSid || !twilioAuthToken)) ||
-    (smsProvider === 'vonage' && (!vonageApiKey || !vonageApiSecret)) ||
-    (smsProvider === 'telnyx' && !telnyxApiKey)
-
-  return (
-    <div className="space-y-6">
-      <ReadOnlyBanner />
-      <ReadOnlyWrapper>
-        <AppCard
-          title={t('sms.title')}
-          description={t('sms.description')}
-          contentClassName="space-y-6"
-        >
-          <div className="flex items-center justify-between">
-            <div className="space-y-0.5">
-              <Label htmlFor="enable-sms">{t('sms.enableLabel')}</Label>
-              <p className="text-xs text-muted-foreground">{t('sms.enableHint')}</p>
-            </div>
-            <Switch id="enable-sms" checked={enabled} onCheckedChange={setEnabled} />
-          </div>
-
-          {!enabled && (
-            <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
-              <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
-              <p className="text-sm text-muted-foreground">{t('sms.disabledInfo')}</p>
-            </div>
-          )}
-
-          {enabled && (
-            <>
-              {/* Provider selection */}
-              <div className="flex flex-wrap gap-2">
-                <Button
-                  type="button"
-                  variant={smsProvider === 'twilio' ? 'default' : 'outline'}
-                  onClick={() => setSmsProvider('twilio')}
-                  className="flex-1"
-                >
-                  Twilio
-                </Button>
-                <Button
-                  type="button"
-                  variant={smsProvider === 'vonage' ? 'default' : 'outline'}
-                  onClick={() => setSmsProvider('vonage')}
-                  className="flex-1"
-                >
-                  Vonage
-                </Button>
-                <Button
-                  type="button"
-                  variant={smsProvider === 'telnyx' ? 'default' : 'outline'}
-                  onClick={() => setSmsProvider('telnyx')}
-                  className="flex-1"
-                >
-                  Telnyx
-                </Button>
-              </div>
-
-              {/* Phone number (shared) */}
-              <div className="space-y-2">
-                <Label htmlFor="sms-phone-number">{t('sms.phoneNumber')}</Label>
-                <Input
-                  id="sms-phone-number"
-                  placeholder={t('sms.phoneNumberPlaceholder')}
-                  value={phoneNumber}
-                  onChange={(e) => setPhoneNumber(e.target.value)}
-                />
-                <p className="text-xs text-muted-foreground">{t('sms.phoneNumberHint')}</p>
-              </div>
-
-              {/* Twilio fields */}
-              {smsProvider === 'twilio' && (
-                <div className="grid gap-4 sm:grid-cols-2">
-                  <div className="space-y-2">
-                    <Label htmlFor="twilio-sid">{t('sms.accountSid')}</Label>
-                    <Input
-                      id="twilio-sid"
-                      type="password"
-                      placeholder="AC••••••••••••••••••••••••••••••••"
-                      value={twilioAccountSid}
-                      onChange={(e) => setTwilioAccountSid(e.target.value)}
-                    />
-                  </div>
-                  <div className="space-y-2">
-                    <Label htmlFor="twilio-token">{t('sms.authToken')}</Label>
-                    <Input
-                      id="twilio-token"
-                      type="password"
-                      placeholder="••••••••••••••••••••••••••••••••"
-                      value={twilioAuthToken}
-                      onChange={(e) => setTwilioAuthToken(e.target.value)}
-                    />
-                  </div>
-                </div>
-              )}
-
-              {/* Vonage fields */}
-              {smsProvider === 'vonage' && (
-                <div className="grid gap-4 sm:grid-cols-2">
-                  <div className="space-y-2">
-                    <Label htmlFor="vonage-key">{t('sms.apiKey')}</Label>
-                    <Input
-                      id="vonage-key"
-                      type="password"
-                      placeholder="••••••••"
-                      value={vonageApiKey}
-                      onChange={(e) => setVonageApiKey(e.target.value)}
-                    />
-                  </div>
-                  <div className="space-y-2">
-                    <Label htmlFor="vonage-secret">{t('sms.apiSecret')}</Label>
-                    <Input
-                      id="vonage-secret"
-                      type="password"
-                      placeholder="••••••••••••••••"
-                      value={vonageApiSecret}
-                      onChange={(e) => setVonageApiSecret(e.target.value)}
-                    />
-                  </div>
-                </div>
-              )}
-
-              {/* Telnyx fields */}
-              {smsProvider === 'telnyx' && (
-                <div className="space-y-2">
-                  <Label htmlFor="telnyx-key">{t('sms.apiKey')}</Label>
-                  <Input
-                    id="telnyx-key"
-                    type="password"
-                    placeholder="KEY••••••••••••••••••••••••••••••••"
-                    value={telnyxApiKey}
-                    onChange={(e) => setTelnyxApiKey(e.target.value)}
-                  />
-                </div>
-              )}
-
-              {/* Webhook URL */}
-              {webhookSecret && (
-                <div className="space-y-2">
-                  <Label>{t('sms.webhookUrl')}</Label>
-                  <div className="flex items-center gap-2">
-                    <Input readOnly value={webhookUrl} className="font-mono text-xs" />
-                    <Button
-                      type="button"
-                      variant="outline"
-                      size="icon"
-                      onClick={handleCopyWebhook}
-                      aria-label={t('sms.copyWebhookUrl')}
-                    >
-                      {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
-                    </Button>
-                  </div>
-                  <p className="text-xs text-muted-foreground">
-                    {t('sms.webhookUrlHint', { provider: smsProvider })}
-                  </p>
-                </div>
-              )}
-
-              {/* Test SMS */}
-              <div className="space-y-3 pt-2">
-                <div className="space-y-2">
-                  <Label htmlFor="test-phone">{t('sms.testPhoneNumber')}</Label>
-                  <div className="flex items-center gap-2">
-                    <Input
-                      id="test-phone"
-                      placeholder={t('sms.testPhonePlaceholder')}
-                      value={testPhone}
-                      onChange={(e) => setTestPhone(e.target.value)}
-                      className="max-w-xs"
-                    />
-                    <Button
-                      type="button"
-                      variant="outline"
-                      onClick={handleTestSms}
-                      disabled={isTestDisabled}
-                    >
-                      {isTesting ? (
-                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                      ) : (
-                        <Send className="mr-2 h-4 w-4" />
-                      )}
-                      {t('sms.sendTestSms')}
-                    </Button>
-                  </div>
-                  <p className="text-xs text-muted-foreground">{t('sms.testSmsHint')}</p>
-                </div>
-              </div>
-            </>
-          )}
-        </AppCard>
-
-        <SaveButton>
-          <div className="flex justify-end">
-            <Button onClick={handleSave} disabled={isPending}>
-              {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {t('sms.saveSettings')}
-            </Button>
-          </div>
-        </SaveButton>
-      </ReadOnlyWrapper>
-    </div>
-  )
-}

+ 0 - 165
src/features/telegram/Actions/telegramSettingsActions.ts

@@ -1,165 +0,0 @@
-'use server'
-
-import crypto from 'crypto'
-import { db } from '@/lib/db'
-import { withAuth } from '@/lib/with-auth'
-import { revalidatePath } from 'next/cache'
-import { demoGuard } from '@/lib/demo'
-import { ALL_ORG_TELEGRAM_KEYS, ORG_TELEGRAM_KEYS } from '../Schema/telegramSettingsSchema'
-import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { armFeatureHints } from '@/features/settings/Lib/armFeatureHints'
-import {
-  getTelegramBotInfo,
-  setTelegramWebhook,
-  deleteTelegramWebhook,
-  sendTelegramMessage,
-} from '@/lib/telegram'
-import { requireFeature } from '@/lib/features'
-
-export async function getTelegramSettings() {
-  return withAuth(
-    async ({ organizationId }) => {
-      const settings = await db.appSetting.findMany({
-        where: { organizationId, key: { in: ALL_ORG_TELEGRAM_KEYS } },
-      })
-      const map: Record<string, string> = {}
-      for (const s of settings) {
-        map[s.key] = s.value
-      }
-      return map
-    },
-    {
-      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
-    }
-  )
-}
-
-export async function setTelegramSettings(settings: { botToken: string }) {
-  return withAuth(
-    async ({ userId, organizationId }) => {
-      demoGuard()
-      await requireFeature(organizationId, 'telegram')
-
-      // Validate bot token by calling getMe
-      const botInfo = await getTelegramBotInfo(settings.botToken)
-
-      // Generate webhook secret
-      const webhookSecret = crypto.randomUUID()
-
-      // Build webhook URL
-      const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.VERCEL_URL || ''
-      const webhookUrl = `${appUrl}/api/webhooks/telegram/${organizationId}?secret=${webhookSecret}`
-
-      // Register webhook with Telegram
-      await setTelegramWebhook(settings.botToken, webhookUrl, webhookSecret)
-
-      // Save all settings via upsert (auto-enable when connecting)
-      const entries: Record<string, string> = {
-        [ORG_TELEGRAM_KEYS.TELEGRAM_ENABLED]: 'true',
-        [ORG_TELEGRAM_KEYS.TELEGRAM_BOT_TOKEN]: settings.botToken,
-        [ORG_TELEGRAM_KEYS.TELEGRAM_BOT_USERNAME]: botInfo.username,
-        [ORG_TELEGRAM_KEYS.TELEGRAM_WEBHOOK_SECRET]: webhookSecret,
-      }
-
-      // Connecting a bot switches Telegram on, which is the same event as
-      // ticking the toggle and deserves the same note about the new link.
-      await armFeatureHints(db, organizationId, userId, entries)
-
-      await db.$transaction(
-        Object.entries(entries).map(([key, value]) =>
-          db.appSetting.upsert({
-            where: { organizationId_key: { organizationId, key } },
-            update: { value },
-            create: { userId, organizationId, key, value },
-          })
-        )
-      )
-
-      revalidatePath('/settings/telegram')
-      return { botUsername: botInfo.username }
-    },
-    {
-      requiredPermissions: [
-        {
-          action: PermissionAction.UPDATE,
-          subject: PermissionSubject.SETTINGS,
-        },
-      ],
-    }
-  )
-}
-
-export async function testTelegramSend(input: { chatId: string; message: string }) {
-  return withAuth(
-    async ({ organizationId }) => {
-      demoGuard()
-      await requireFeature(organizationId, 'telegram')
-
-      if (!input.chatId?.trim()) {
-        throw new Error('Please enter a chat ID to send the test message to')
-      }
-
-      await sendTelegramMessage(organizationId, {
-        chatId: input.chatId.trim(),
-        text:
-          input.message?.trim() ||
-          'Test message from Torqvoice -- your Telegram bot is configured correctly.',
-      })
-
-      return { sentTo: input.chatId.trim() }
-    },
-    {
-      requiredPermissions: [
-        {
-          action: PermissionAction.UPDATE,
-          subject: PermissionSubject.SETTINGS,
-        },
-      ],
-    }
-  )
-}
-
-export async function disconnectTelegram() {
-  return withAuth(
-    async ({ organizationId }) => {
-      demoGuard()
-      // Get current bot token to delete webhook
-      const tokenSetting = await db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId,
-            key: ORG_TELEGRAM_KEYS.TELEGRAM_BOT_TOKEN,
-          },
-        },
-      })
-
-      if (tokenSetting?.value) {
-        try {
-          await deleteTelegramWebhook(tokenSetting.value)
-        } catch (error) {
-          // Log but continue — we still want to remove local settings
-          console.error('[disconnectTelegram] Failed to delete webhook:', error)
-        }
-      }
-
-      // Remove all telegram settings
-      await db.appSetting.deleteMany({
-        where: {
-          organizationId,
-          key: { in: ALL_ORG_TELEGRAM_KEYS },
-        },
-      })
-
-      revalidatePath('/settings/telegram')
-      return { disconnected: true }
-    },
-    {
-      requiredPermissions: [
-        {
-          action: PermissionAction.UPDATE,
-          subject: PermissionSubject.SETTINGS,
-        },
-      ],
-    }
-  )
-}

+ 0 - 66
src/features/telegram/Components/TelegramDisconnectButton.tsx

@@ -1,66 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { useTranslations } from 'next-intl'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-import { Button } from '@/components/ui/button'
-import {
-  AlertDialog,
-  AlertDialogAction,
-  AlertDialogCancel,
-  AlertDialogContent,
-  AlertDialogDescription,
-  AlertDialogFooter,
-  AlertDialogHeader,
-  AlertDialogTitle,
-  AlertDialogTrigger,
-} from '@/components/ui/alert-dialog'
-import { Loader2 } from 'lucide-react'
-import { disconnectTelegram } from '../Actions/telegramSettingsActions'
-
-export function TelegramDisconnectButton() {
-  const t = useTranslations('telegram')
-  const tc = useTranslations('common.buttons')
-  const router = useRouter()
-  const [isDisconnecting, setIsDisconnecting] = useState(false)
-
-  const handleDisconnect = async () => {
-    setIsDisconnecting(true)
-    const result = await disconnectTelegram()
-    if (result.success) {
-      toast.success(t('disconnect.success'))
-      router.refresh()
-    } else {
-      toast.error(result.error ?? t('disconnect.error'))
-    }
-    setIsDisconnecting(false)
-  }
-
-  return (
-    <AlertDialog>
-      <AlertDialogTrigger asChild>
-        <Button type="button" variant="destructive">
-          {t('disconnect.button')}
-        </Button>
-      </AlertDialogTrigger>
-      <AlertDialogContent>
-        <AlertDialogHeader>
-          <AlertDialogTitle>{t('disconnect.title')}</AlertDialogTitle>
-          <AlertDialogDescription>{t('disconnect.description')}</AlertDialogDescription>
-        </AlertDialogHeader>
-        <AlertDialogFooter>
-          <AlertDialogCancel disabled={isDisconnecting}>{tc('cancel')}</AlertDialogCancel>
-          <AlertDialogAction
-            onClick={handleDisconnect}
-            disabled={isDisconnecting}
-            className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
-          >
-            {isDisconnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-            {t('disconnect.button')}
-          </AlertDialogAction>
-        </AlertDialogFooter>
-      </AlertDialogContent>
-    </AlertDialog>
-  )
-}

+ 0 - 197
src/features/telegram/Components/TelegramSettingsForm.tsx

@@ -1,197 +0,0 @@
-'use client'
-
-import { useState, useTransition } from 'react'
-import { useTranslations } from 'next-intl'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import { AppCard } from '@/components/app-card'
-import { Switch } from '@/components/ui/switch'
-import { ExternalLink, Info, Loader2, Copy, Check, Eye, EyeOff } from 'lucide-react'
-import { setTelegramSettings } from '../Actions/telegramSettingsActions'
-import { setSettings } from '@/features/settings/Actions/settingsActions'
-import {
-  ReadOnlyBanner,
-  SaveButton,
-  ReadOnlyWrapper,
-} from '@/app/(authenticated)/settings/read-only-guard'
-import { TelegramDisconnectButton } from './TelegramDisconnectButton'
-import { TelegramTestMessage } from './TelegramTestMessage'
-
-export function TelegramSettingsForm({
-  initial,
-  appUrl,
-  initialEnabled = false,
-}: {
-  initial: Record<string, string>
-  appUrl: string
-  initialEnabled?: boolean
-}) {
-  const t = useTranslations('telegram')
-  const router = useRouter()
-  const [isPending, startTransition] = useTransition()
-  const [showToken, setShowToken] = useState(false)
-  const [enabled, setEnabled] = useState(initialEnabled)
-  const [botToken, setBotToken] = useState(initial['telegram.botToken'] || '')
-  const botUsername = initial['telegram.botUsername'] || ''
-  const isConnected = !!botUsername
-  const [copied, setCopied] = useState(false)
-  const deepLink = botUsername ? `https://t.me/${botUsername}?start={customerId}` : ''
-
-  const handleCopyLink = () => {
-    if (!deepLink) return
-    navigator.clipboard.writeText(deepLink)
-    setCopied(true)
-    toast.success(t('deepLink.copied'))
-    setTimeout(() => setCopied(false), 2000)
-  }
-
-  const handleToggleEnabled = (checked: boolean) => {
-    setEnabled(checked)
-    startTransition(async () => {
-      const result = await setSettings({ 'telegram.enabled': checked ? 'true' : 'false' })
-      if (result.success) {
-        toast.success(t('saved'))
-        router.refresh()
-      } else {
-        toast.error(result.error ?? t('saveError'))
-      }
-    })
-  }
-
-  const handleSave = () => {
-    startTransition(async () => {
-      const result = await setTelegramSettings({ botToken })
-      if (result.success) {
-        toast.success(t('saved'))
-        router.refresh()
-      } else {
-        toast.error(result.error ?? t('saveError'))
-      }
-    })
-  }
-
-  return (
-    <div className="space-y-6">
-      <ReadOnlyBanner />
-      <ReadOnlyWrapper>
-        <AppCard
-          title={t('title')}
-          description={
-            <>
-              {t('description')}{' '}
-              <a
-                href="https://torqvoice.com/docs/integrations/telegram"
-                target="_blank"
-                rel="noopener noreferrer"
-                className="inline-flex items-center gap-1 font-medium text-primary hover:underline"
-              >
-                {' '}
-                {t('helpLink')} <ExternalLink className="h-3 w-3" />{' '}
-              </a>
-            </>
-          }
-          contentClassName="space-y-6"
-        >
-          <div className="flex items-center justify-between">
-            <div className="space-y-0.5">
-              <Label htmlFor="enable-telegram">{t('enable.label')}</Label>
-              <p className="text-xs text-muted-foreground">{t('enable.hint')}</p>
-            </div>
-            <Switch id="enable-telegram" checked={enabled} onCheckedChange={handleToggleEnabled} />
-          </div>
-
-          {!enabled && (
-            <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
-              <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
-              <p className="text-sm text-muted-foreground">{t('enable.disabledInfo')}</p>
-            </div>
-          )}
-
-          {enabled && (
-            <>
-              <div className="space-y-2">
-                <Label htmlFor="bot-token">{t('botToken.label')}</Label>
-                <div className="flex items-center gap-2">
-                  <Input
-                    id="bot-token"
-                    type={showToken ? 'text' : 'password'}
-                    placeholder={t('botToken.placeholder')}
-                    value={botToken}
-                    onChange={(e) => setBotToken(e.target.value)}
-                  />
-                  <Button
-                    type="button"
-                    variant="outline"
-                    size="icon"
-                    onClick={() => setShowToken(!showToken)}
-                    aria-label={showToken ? t('botToken.hide') : t('botToken.show')}
-                  >
-                    {showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
-                  </Button>
-                </div>
-                <p className="text-xs text-muted-foreground">{t('botToken.help')}</p>
-              </div>
-
-              {isConnected && (
-                <div className="space-y-2">
-                  <Label>{t('botUsername.label')}</Label>
-                  <Input readOnly value={`@${botUsername}`} className="font-mono text-sm" />
-                </div>
-              )}
-
-              {isConnected && (
-                <div className="space-y-2">
-                  <Label>{t('webhook.label')}</Label>
-                  <Input
-                    readOnly
-                    value={`${appUrl}/api/webhooks/telegram`}
-                    className="font-mono text-xs"
-                  />
-                  <p className="text-xs text-muted-foreground">{t('webhook.description')}</p>
-                </div>
-              )}
-
-              {isConnected && (
-                <div className="space-y-2">
-                  <Label>{t('deepLink.title')}</Label>
-                  <p className="text-xs text-muted-foreground">{t('deepLink.description')}</p>
-                  <div className="flex items-center gap-2">
-                    <Input readOnly value={deepLink} className="font-mono text-xs" />
-                    <Button
-                      type="button"
-                      variant="outline"
-                      size="icon"
-                      onClick={handleCopyLink}
-                      aria-label={t('deepLink.copy')}
-                    >
-                      {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
-                    </Button>
-                  </div>
-                </div>
-              )}
-
-              {isConnected && <TelegramTestMessage />}
-            </>
-          )}
-        </AppCard>
-
-        {enabled && (
-          <SaveButton>
-            <div className="flex items-center justify-between">
-              {isConnected && <TelegramDisconnectButton />}
-              <div className="ml-auto">
-                <Button onClick={handleSave} disabled={isPending || !botToken.trim()}>
-                  {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                  {isPending ? t('saving') : t('save')}
-                </Button>
-              </div>
-            </div>
-          </SaveButton>
-        )}
-      </ReadOnlyWrapper>
-    </div>
-  )
-}

+ 0 - 75
src/features/telegram/Components/TelegramTestMessage.tsx

@@ -1,75 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { useTranslations } from 'next-intl'
-import { toast } from 'sonner'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
-import { Loader2, Send } from 'lucide-react'
-import { testTelegramSend } from '../Actions/telegramSettingsActions'
-
-export function TelegramTestMessage() {
-  const t = useTranslations('telegram')
-  const [isTesting, setIsTesting] = useState(false)
-  const [testChatId, setTestChatId] = useState('')
-  const [testMessage, setTestMessage] = useState('')
-
-  const handleTestSend = async () => {
-    if (!testChatId.trim() || !testMessage.trim()) return
-    setIsTesting(true)
-    try {
-      const result = await testTelegramSend({
-        chatId: testChatId.trim(),
-        message: testMessage.trim(),
-      })
-      if (result.success) {
-        toast.success(t('test.success'))
-      } else {
-        toast.error(result.error ?? t('test.error'))
-      }
-    } finally {
-      setIsTesting(false)
-    }
-  }
-
-  return (
-    <div className="space-y-3 border-t pt-4">
-      <Label className="text-base font-semibold">{t('test.title')}</Label>
-      <div className="space-y-2">
-        <Label htmlFor="test-chat-id">{t('test.chatId')}</Label>
-        <Input
-          id="test-chat-id"
-          placeholder={t('test.chatIdPlaceholder')}
-          value={testChatId}
-          onChange={(e) => setTestChatId(e.target.value)}
-          className="max-w-xs"
-        />
-      </div>
-      <div className="space-y-2">
-        <Label htmlFor="test-message">{t('test.message')}</Label>
-        <Textarea
-          id="test-message"
-          placeholder={t('test.messagePlaceholder')}
-          value={testMessage}
-          onChange={(e) => setTestMessage(e.target.value)}
-          rows={3}
-        />
-      </div>
-      <Button
-        type="button"
-        variant="outline"
-        onClick={handleTestSend}
-        disabled={isTesting || !testChatId.trim() || !testMessage.trim()}
-      >
-        {isTesting ? (
-          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-        ) : (
-          <Send className="mr-2 h-4 w-4" />
-        )}
-        {t('test.send')}
-      </Button>
-    </div>
-  )
-}

+ 0 - 415
src/features/whatsapp/Actions/whatsappSettingsActions.ts

@@ -1,415 +0,0 @@
-'use server'
-
-import crypto from 'crypto'
-import { db } from '@/lib/db'
-import { withAuth } from '@/lib/with-auth'
-import { revalidatePath } from 'next/cache'
-import { demoGuard } from '@/lib/demo'
-import { requireFeature } from '@/lib/features'
-import { PermissionAction, PermissionSubject } from '@/lib/permissions'
-import { armFeatureHints } from '@/features/settings/Lib/armFeatureHints'
-import {
-  ALL_ORG_WHATSAPP_KEYS,
-  SECRET_MASK,
-  ORG_WHATSAPP_KEYS,
-  WHATSAPP_WEBHOOK_TOKEN_FIELD,
-  whatsappCredentialKey,
-  whatsappTemplateKey,
-} from '../Schema/whatsappSettingsSchema'
-import { getWhatsappAdapter, listWhatsappProviderOptions } from '@/lib/whatsapp/registry'
-import { getWhatsappWebhookContext, sendOrgWhatsapp, WHATSAPP_MEDIA_PATH } from '@/lib/whatsapp'
-import { TEMPLATE_TOKENS, unknownTemplateTokens } from '../Schema/templateTokens'
-
-export interface TemplateFields {
-  name: string
-  language: string
-  variables: string
-}
-
-export interface WhatsappSettingsView {
-  enabled: boolean
-  provider: string | null
-  from: string
-  /** Per provider, then per kind: the identifiers are provider-shaped. */
-  templates: Record<string, { text: TemplateFields; media: TemplateFields }>
-  /** Per provider, field name to value, with secrets masked. */
-  credentials: Record<string, Record<string, string>>
-  /**
-   * Where each provider should post, ready to paste into its console.
-   *
-   * One per provider rather than one for the saved provider: the form shows
-   * the URL for whichever is selected, and a workshop switching provider needs
-   * the new address before it has saved anything.
-   */
-  webhookUrls: Record<string, string>
-  /**
-   * What a media template's URL field needs, with the variable left for the
-   * workshop to place, e.g. https://app.example.com/api/public/whatsapp-media/
-   */
-  mediaUrlPrefix: string
-  /** When the provider first reached the webhook, ISO, or null if never. */
-  webhookSeenAt: string | null
-  /** Whether anything has actually been sent or received yet. */
-  hasMessages: boolean
-  providers: ReturnType<typeof listWhatsappProviderOptions>
-}
-
-function appUrl(): string {
-  return process.env.NEXT_PUBLIC_APP_URL || process.env.VERCEL_URL || ''
-}
-
-/**
- * The webhook URL for one provider, including the shared token when the
- * provider has no signature of its own to prove the call came from it.
- */
-function webhookUrlFor(organizationId: string, providerId: string, token?: string): string {
-  const base = `${appUrl()}/api/webhooks/whatsapp/${providerId}/${organizationId}`
-  return token ? `${base}?token=${token}` : base
-}
-
-/**
- * One template's fields for one provider, falling back to the flat keys used
- * before templates were namespaced.
- */
-function readTemplateFields(
-  settings: Map<string, string>,
-  provider: string,
-  kind: 'text' | 'media'
-): TemplateFields {
-  const legacy = {
-    text: {
-      name: ORG_WHATSAPP_KEYS.WHATSAPP_TEMPLATE_NAME,
-      language: ORG_WHATSAPP_KEYS.WHATSAPP_TEMPLATE_LANGUAGE,
-      variables: ORG_WHATSAPP_KEYS.WHATSAPP_TEMPLATE_VARIABLES,
-    },
-    media: {
-      name: ORG_WHATSAPP_KEYS.WHATSAPP_MEDIA_TEMPLATE_NAME,
-      language: ORG_WHATSAPP_KEYS.WHATSAPP_MEDIA_TEMPLATE_LANGUAGE,
-      variables: ORG_WHATSAPP_KEYS.WHATSAPP_MEDIA_TEMPLATE_VARIABLES,
-    },
-  } as const
-  const wasThisProvider = settings.get(ORG_WHATSAPP_KEYS.WHATSAPP_PROVIDER) === provider
-
-  const read = (field: 'name' | 'language' | 'variables') =>
-    settings.get(whatsappTemplateKey(provider, kind, field)) ??
-    (wasThisProvider ? (settings.get(legacy[kind][field]) ?? '') : '')
-
-  return { name: read('name'), language: read('language'), variables: read('variables') }
-}
-
-export async function getWhatsappSettings() {
-  return withAuth(
-    async ({ organizationId }): Promise<WhatsappSettingsView> => {
-      const rows = await db.appSetting.findMany({
-        where: { organizationId },
-        select: { key: true, value: true },
-      })
-      const settings = new Map(rows.map((row) => [row.key, row.value]))
-      const providers = listWhatsappProviderOptions()
-
-      const credentials: Record<string, Record<string, string>> = {}
-      for (const provider of providers) {
-        const fields: Record<string, string> = {}
-        for (const field of provider.credentials) {
-          const value = settings.get(whatsappCredentialKey(provider.id, field.key))
-          if (!value) continue
-          fields[field.key] = field.secret ? SECRET_MASK : value
-        }
-        credentials[provider.id] = fields
-      }
-
-      const templates: WhatsappSettingsView['templates'] = {}
-      for (const provider of providers) {
-        templates[provider.id] = {
-          text: readTemplateFields(settings, provider.id, 'text'),
-          media: readTemplateFields(settings, provider.id, 'media'),
-        }
-      }
-
-      const providerId = settings.get(ORG_WHATSAPP_KEYS.WHATSAPP_PROVIDER) ?? null
-
-      const webhookUrls: Record<string, string> = {}
-      for (const provider of providers) {
-        const token = provider.usesWebhookToken
-          ? settings.get(whatsappCredentialKey(provider.id, WHATSAPP_WEBHOOK_TOKEN_FIELD))
-          : undefined
-        webhookUrls[provider.id] = webhookUrlFor(organizationId, provider.id, token)
-      }
-
-      return {
-        enabled: settings.get(ORG_WHATSAPP_KEYS.WHATSAPP_ENABLED) === 'true',
-        provider: providerId,
-        from: settings.get(ORG_WHATSAPP_KEYS.WHATSAPP_FROM) ?? '',
-        templates,
-        credentials,
-        webhookUrls,
-        mediaUrlPrefix: `${appUrl().replace(/\/$/, '')}${WHATSAPP_MEDIA_PATH}/`,
-        webhookSeenAt: settings.get(ORG_WHATSAPP_KEYS.WHATSAPP_WEBHOOK_SEEN_AT) ?? null,
-        hasMessages: (await db.whatsappMessage.count({ where: { organizationId } })) > 0,
-        providers,
-      }
-    },
-    {
-      requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
-    }
-  )
-}
-
-export interface SaveWhatsappSettingsInput {
-  enabled: boolean
-  provider: string
-  from: string
-  templateName?: string
-  templateLanguage?: string
-  /** Comma-separated tokens filling the template's placeholders, in order. */
-  templateVariables?: string
-  mediaTemplateName?: string
-  mediaTemplateLanguage?: string
-  mediaTemplateVariables?: string
-  /** Only the fields the workshop actually typed; masked ones are ignored. */
-  credentials: Record<string, string>
-}
-
-export async function saveWhatsappSettings(input: SaveWhatsappSettingsInput) {
-  return withAuth(
-    async ({ userId, organizationId }) => {
-      demoGuard()
-      await requireFeature(organizationId, 'whatsapp')
-
-      const adapter = getWhatsappAdapter(input.provider)
-      if (!adapter) throw new Error(`Unknown WhatsApp provider "${input.provider}".`)
-
-      const entries: Record<string, string> = {
-        [ORG_WHATSAPP_KEYS.WHATSAPP_ENABLED]: input.enabled ? 'true' : 'false',
-        [ORG_WHATSAPP_KEYS.WHATSAPP_PROVIDER]: adapter.id,
-        [ORG_WHATSAPP_KEYS.WHATSAPP_FROM]: input.from.trim(),
-        [whatsappTemplateKey(adapter.id, 'text', 'name')]: input.templateName?.trim() ?? '',
-        [whatsappTemplateKey(adapter.id, 'text', 'language')]: input.templateLanguage?.trim() ?? '',
-        [whatsappTemplateKey(adapter.id, 'text', 'variables')]:
-          input.templateVariables?.trim() ?? '',
-        [whatsappTemplateKey(adapter.id, 'media', 'name')]: input.mediaTemplateName?.trim() ?? '',
-        [whatsappTemplateKey(adapter.id, 'media', 'language')]:
-          input.mediaTemplateLanguage?.trim() ?? '',
-        [whatsappTemplateKey(adapter.id, 'media', 'variables')]:
-          input.mediaTemplateVariables?.trim() ?? '',
-      }
-
-      // What is already stored, so an untouched field still counts as filled in.
-      const storedRows = await db.appSetting.findMany({
-        where: {
-          organizationId,
-          key: {
-            in: adapter.credentials.map((field) => whatsappCredentialKey(adapter.id, field.key)),
-          },
-        },
-        select: { key: true, value: true },
-      })
-      const stored = new Map(storedRows.map((row) => [row.key, row.value]))
-
-      const effective: Record<string, string> = {}
-      for (const field of adapter.credentials) {
-        const key = whatsappCredentialKey(adapter.id, field.key)
-        const typed = input.credentials[field.key]
-        // An untouched secret comes back as its mask, which must never be
-        // written over the real one.
-        if (typed === undefined || typed === SECRET_MASK) {
-          effective[field.key] = stored.get(key) ?? ''
-          continue
-        }
-        // A paste into a masked field can land beside the mask rather than
-        // replacing it. The bullets are not part of any credential, so they
-        // come off here as well as in the form.
-        const cleaned = typed.replaceAll('\u2022', '').trim()
-        entries[key] = cleaned
-        effective[field.key] = cleaned
-      }
-
-      // Providers that sign nothing rely on the URL being unguessable, so the
-      // token is ours to mint and it must survive a settings re-save.
-      if (adapter.usesWebhookToken) {
-        const key = whatsappCredentialKey(adapter.id, WHATSAPP_WEBHOOK_TOKEN_FIELD)
-        const existing = await db.appSetting.findUnique({
-          where: { organizationId_key: { organizationId, key } },
-          select: { value: true },
-        })
-        if (!existing?.value) entries[key] = crypto.randomUUID().replace(/-/g, '')
-      }
-
-      // Catch a template that cannot possibly work before it fails mid-send,
-      // where the provider's own wording is rarely more than "Invalid Parameter".
-      for (const name of [input.templateName?.trim(), input.mediaTemplateName?.trim()]) {
-        if (!name) continue
-        const problem = adapter.template.validate?.(name)
-        if (problem) throw new Error(problem)
-      }
-
-      // A token that fills nothing would reach WhatsApp as a literal word.
-      const unknown = [
-        ...unknownTemplateTokens(input.templateVariables),
-        ...unknownTemplateTokens(input.mediaTemplateVariables),
-      ]
-      if (unknown.length > 0) {
-        throw new Error(
-          `Unknown template values: ${[...new Set(unknown)].join(', ')}. Use ${TEMPLATE_TOKENS.join(', ')}.`
-        )
-      }
-
-      // Saved either way, and reported rather than refused. Meta hands out the
-      // phone number ID only after the webhook has been verified, so a
-      // workshop has to be able to store a verify token before it has the
-      // rest. Sending stays unavailable until the setup is complete, which
-      // getWhatsappConfig decides on its own.
-      const missing = adapter.credentials
-        .filter((field) => field.required)
-        .filter((field) => !effective[field.key])
-        .map((field) => field.label)
-      if (!entries[ORG_WHATSAPP_KEYS.WHATSAPP_FROM]) missing.push('WhatsApp number')
-
-      await armFeatureHints(db, organizationId, userId, entries)
-
-      await db.$transaction(
-        Object.entries(entries).map(([key, value]) =>
-          db.appSetting.upsert({
-            where: { organizationId_key: { organizationId, key } },
-            update: { value },
-            create: { userId, organizationId, key, value },
-          })
-        )
-      )
-
-      revalidatePath('/settings/whatsapp')
-      revalidatePath('/whatsapp')
-
-      const token = adapter.usesWebhookToken
-        ? (entries[whatsappCredentialKey(adapter.id, WHATSAPP_WEBHOOK_TOKEN_FIELD)] ??
-          (
-            await db.appSetting.findUnique({
-              where: {
-                organizationId_key: {
-                  organizationId,
-                  key: whatsappCredentialKey(adapter.id, WHATSAPP_WEBHOOK_TOKEN_FIELD),
-                },
-              },
-              select: { value: true },
-            })
-          )?.value)
-        : undefined
-
-      return {
-        webhookUrl: webhookUrlFor(organizationId, adapter.id, token),
-        /** Still needed before anything can be sent. */
-        missing,
-      }
-    },
-    {
-      requiredPermissions: [
-        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
-      ],
-      audit: { action: 'settings.whatsappUpdated', message: 'Updated WhatsApp settings' },
-    }
-  )
-}
-
-/**
- * Registers the business number with the provider, where that is a step.
- *
- * Meta wants this after the number is verified and it is what the console's
- * Register button does, except that the console never says why it failed. The
- * provider's own error is passed through, because the codes are what its
- * documentation is indexed by.
- */
-export async function registerWhatsappNumber(pin: string) {
-  return withAuth(
-    async ({ organizationId }) => {
-      demoGuard()
-      await requireFeature(organizationId, 'whatsapp')
-
-      if (!/^\d{6}$/.test(pin)) {
-        throw new Error('The PIN must be exactly six digits.')
-      }
-
-      const providerId = await db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId,
-            key: ORG_WHATSAPP_KEYS.WHATSAPP_PROVIDER,
-          },
-        },
-        select: { value: true },
-      })
-
-      const resolved = providerId?.value
-        ? await getWhatsappWebhookContext(organizationId, providerId.value)
-        : null
-      if (!resolved?.adapter.registerNumber) {
-        throw new Error('This provider registers numbers for you; there is nothing to do here.')
-      }
-
-      await resolved.adapter.registerNumber(resolved.context, pin)
-      return { registered: true }
-    },
-    {
-      requiredPermissions: [
-        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
-      ],
-      audit: { action: 'settings.whatsappNumberRegistered', message: 'Registered WhatsApp number' },
-    }
-  )
-}
-
-/**
- * Forgets the whole setup, credentials included.
- *
- * Turning the toggle off would leave an access token in the database of a
- * workshop that has decided it no longer wants us holding one.
- */
-export async function disconnectWhatsapp() {
-  return withAuth(
-    async ({ organizationId }) => {
-      demoGuard()
-
-      await db.appSetting.deleteMany({
-        where: {
-          organizationId,
-          OR: [{ key: { in: ALL_ORG_WHATSAPP_KEYS } }, { key: { startsWith: 'whatsapp.cred.' } }],
-        },
-      })
-
-      revalidatePath('/settings/whatsapp')
-      revalidatePath('/whatsapp')
-      return { disconnected: true }
-    },
-    {
-      requiredPermissions: [
-        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
-      ],
-      audit: { action: 'settings.whatsappDisconnected', message: 'Disconnected WhatsApp' },
-    }
-  )
-}
-
-/**
- * Sends a message to the workshop's own number, which is the only way to find
- * out whether the credentials work before a customer is on the other end.
- */
-export async function sendWhatsappTestMessage(to: string) {
-  return withAuth(
-    async ({ organizationId }) => {
-      demoGuard()
-      await requireFeature(organizationId, 'whatsapp')
-
-      await sendOrgWhatsapp(organizationId, {
-        to,
-        body: 'Test message from your workshop. WhatsApp is connected.',
-        relatedEntityType: 'settings',
-        relatedEntityId: 'whatsapp-test',
-      })
-      return { sent: true }
-    },
-    {
-      requiredPermissions: [
-        { action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS },
-      ],
-    }
-  )
-}

+ 0 - 164
src/features/whatsapp/Components/TemplateSetupFields.tsx

@@ -1,164 +0,0 @@
-'use client'
-
-import { useTranslations } from 'next-intl'
-import { toast } from 'sonner'
-import { Copy } from 'lucide-react'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Button } from '@/components/ui/button'
-import { TemplateVariablePicker } from './TemplateVariablePicker'
-import { TEMPLATE_TOKENS, type TemplateToken } from '../Schema/templateTokens'
-import type { WhatsappSettingsView } from '../Actions/whatsappSettingsActions'
-
-type Provider = WhatsappSettingsView['providers'][number]
-
-/**
- * One approved template: what it is called, what language it was approved in,
- * and which of our values fill its blanks.
- *
- * Rendered twice, because WhatsApp fixes a template's media type at approval.
- * A template approved with an image can only ever send an image, so a workshop
- * that wants both plain updates and part photos needs one of each.
- */
-export function TemplateSetupFields({
-  kind,
-  provider,
-  name,
-  onName,
-  language,
-  onLanguage,
-  variables,
-  onVariables,
-  mediaUrlPrefix,
-}: {
-  kind: 'text' | 'media'
-  provider: Provider | null
-  name: string
-  onName: (next: string) => void
-  language: string
-  onLanguage: (next: string) => void
-  variables: string
-  onVariables: (next: string) => void
-  /** Only needed by the photo template, and only on providers that ask for it. */
-  mediaUrlPrefix?: string
-}) {
-  const t = useTranslations('whatsapp.settings.template')
-
-  // The photo value belongs to the photo template alone, and only where the
-  // provider takes media through the URL rather than as a header of its own.
-  const takesPhotoValue = kind === 'media' && provider?.template.mediaAs === 'variable'
-  const offered: readonly TemplateToken[] = takesPhotoValue
-    ? TEMPLATE_TOKENS
-    : TEMPLATE_TOKENS.filter((token) => token !== 'photo')
-
-  const copyMediaUrl = () => {
-    if (!mediaUrlPrefix) return
-    navigator.clipboard.writeText(`${mediaUrlPrefix}{{n}}`)
-    toast.success(t('mediaUrlCopied'))
-  }
-
-  return (
-    <div className="space-y-4 rounded-lg border p-4">
-      <div className="space-y-0.5">
-        <p className="text-sm font-medium">{t(kind === 'media' ? 'mediaTitle' : 'textTitle')}</p>
-        <p className="text-xs text-muted-foreground">
-          {t(kind === 'media' ? 'mediaDescription' : 'textDescription')}
-        </p>
-        {/* An empty template is not an error, but it does close off a whole
-            way of reaching customers, which is worth saying here. */}
-        {!name.trim() && (
-          <p className="text-xs font-medium text-amber-700 dark:text-amber-400">
-            {t(kind === 'media' ? 'mediaMissing' : 'textMissing')}
-          </p>
-        )}
-      </div>
-
-      <div className="grid gap-4 md:grid-cols-2">
-        <div className="space-y-2">
-          <Label htmlFor={`whatsapp-template-${kind}`}>
-            {provider?.template.label ?? t('nameLabel')}
-          </Label>
-          <Input
-            id={`whatsapp-template-${kind}`}
-            name={`whatsapp-template-${kind}`}
-            value={name}
-            onChange={(event) => onName(event.target.value)}
-            placeholder={provider?.template.placeholder}
-            autoComplete="off"
-            data-1p-ignore
-            data-lpignore="true"
-          />
-          <p className="text-xs text-muted-foreground">
-            {provider?.template.help ?? t('nameHint')}
-          </p>
-        </div>
-
-        {provider?.template.usesLanguage !== false && (
-          <div className="space-y-2">
-            <Label htmlFor={`whatsapp-template-language-${kind}`}>{t('languageLabel')}</Label>
-            <Input
-              id={`whatsapp-template-language-${kind}`}
-              name={`whatsapp-template-language-${kind}`}
-              value={language}
-              onChange={(event) => onLanguage(event.target.value)}
-              placeholder="de"
-              autoComplete="off"
-              data-1p-ignore
-              data-lpignore="true"
-            />
-            <p className="text-xs text-muted-foreground">{t('languageHint')}</p>
-          </div>
-        )}
-      </div>
-
-      <div className="rounded-lg bg-muted/40 p-3">
-        <p className="text-xs font-medium">{t('exampleLabel')}</p>
-        <code className="mt-1 block font-mono text-xs text-muted-foreground">
-          {kind === 'media'
-            ? 'Hi {{1}}, here is a photo from your repair: {{2}}. Reply here if you have any questions.'
-            : 'Hi {{1}}, an update on your repair: {{2}}. Reply here if you have any questions.'}
-        </code>
-        <p className="mt-1.5 text-xs text-muted-foreground">
-          {/* Only a provider that takes media through the URL asks for a value
-              for it. Meta carries the photo in the header, so mentioning one
-              here would send people looking for a chip that is not offered. */}
-          {t(
-            kind === 'media'
-              ? takesPhotoValue
-                ? 'exampleMediaValues'
-                : 'exampleMediaValuesHeader'
-              : 'exampleTextValues'
-          )}
-        </p>
-      </div>
-
-      <div className="space-y-2">
-        <Label>{t('variablesLabel')}</Label>
-        <TemplateVariablePicker value={variables} onChange={onVariables} offered={offered} />
-        <p className="text-xs text-muted-foreground">{t('variablesHint')}</p>
-      </div>
-
-      {/* A media template's URL field is validated as a real URL, so the
-          workshop pastes this prefix and puts the variable at the end. */}
-      {takesPhotoValue && mediaUrlPrefix && variables.includes('photo') && (
-        <div className="space-y-1 rounded-lg border border-dashed bg-muted/30 p-3">
-          <p className="text-xs font-medium">{t('mediaUrlLabel')}</p>
-          <div className="flex items-center gap-2">
-            <Input readOnly value={`${mediaUrlPrefix}{{n}}`} className="font-mono text-xs" />
-            <Button
-              type="button"
-              variant="outline"
-              size="icon"
-              onClick={copyMediaUrl}
-              aria-label={t('mediaUrlCopy')}
-            >
-              <Copy className="h-4 w-4" />
-            </Button>
-          </div>
-          <p className="text-xs text-muted-foreground">{t('mediaUrlHint')}</p>
-          <p className="text-xs text-muted-foreground">{t('mediaUrlSample')}</p>
-        </div>
-      )}
-    </div>
-  )
-}

+ 0 - 682
src/features/whatsapp/Components/WhatsappSettingsForm.tsx

@@ -1,682 +0,0 @@
-'use client'
-
-import { useMemo, useRef, useState, useTransition } from 'react'
-import { useTranslations } from 'next-intl'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-import { AppCard } from '@/components/app-card'
-import { DocsLink } from '@/components/docs-link'
-import { Input } from '@/components/ui/input'
-import { cn } from '@/lib/utils'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
-import {
-  Stepper,
-  StepperIndicator,
-  StepperItem,
-  StepperSeparator,
-  StepperTitle,
-  StepperTrigger,
-} from '@/components/ui/stepper'
-import {
-  ArrowLeft,
-  ArrowRight,
-  Check,
-  Copy,
-  ExternalLink,
-  Eye,
-  EyeOff,
-  Info,
-  Loader2,
-  RefreshCw,
-} from 'lucide-react'
-import {
-  ReadOnlyBanner,
-  ReadOnlyWrapper,
-  SaveButton,
-} from '@/app/(authenticated)/settings/read-only-guard'
-import { TemplateSetupFields } from './TemplateSetupFields'
-import { SECRET_MASK } from '../Schema/whatsappSettingsSchema'
-import {
-  disconnectWhatsapp,
-  registerWhatsappNumber,
-  saveWhatsappSettings,
-  sendWhatsappTestMessage,
-  type WhatsappSettingsView,
-} from '../Actions/whatsappSettingsActions'
-
-/**
- * WhatsApp setup, one step at a time.
- *
- * The order is imposed by the providers, not chosen by us: a webhook can only
- * be verified once its settings are stored, and Meta hands out a phone number
- * ID only after that verification. As a single form that was invisible, so
- * people saved a half-filled page unsure whether they had gone about it in the
- * wrong order or hit a bug.
- *
- * Which step is on screen is a cursor; whether a step is done is read from
- * what is actually stored, so coming back next week shows real progress rather
- * than a wizard reset to the beginning.
- */
-export function WhatsappSettingsForm({ initial }: { initial: WhatsappSettingsView }) {
-  const t = useTranslations('whatsapp.settings')
-  const router = useRouter()
-  const [isPending, startTransition] = useTransition()
-
-  const [enabled, setEnabled] = useState(initial.enabled)
-  const [providerId, setProviderId] = useState(initial.provider ?? initial.providers[0]?.id ?? '')
-  const [from, setFrom] = useState(initial.from)
-  const [templates, setTemplates] = useState(initial.templates)
-  const [credentials, setCredentials] = useState(initial.credentials)
-  const [revealed, setRevealed] = useState<Record<string, boolean>>({})
-  const [missing, setMissing] = useState<string[]>([])
-  const [copied, setCopied] = useState(false)
-  const [testNumber, setTestNumber] = useState('')
-  const [registrationPin, setRegistrationPin] = useState('')
-  const [webhookUrls, setWebhookUrls] = useState(initial.webhookUrls)
-  const [current, setCurrent] = useState(1)
-
-  const provider = useMemo(
-    () => initial.providers.find((option) => option.id === providerId) ?? null,
-    [initial.providers, providerId]
-  )
-  const webhookUrl = webhookUrls[providerId] ?? ''
-  const blankTemplate = { name: '', language: '', variables: '' }
-  const template = templates[providerId]?.text ?? blankTemplate
-  const mediaTemplate = templates[providerId]?.media ?? blankTemplate
-
-  const setTemplate = (
-    kind: 'text' | 'media',
-    field: keyof typeof blankTemplate,
-    value: string
-  ) => {
-    setTemplates((previous) => ({
-      ...previous,
-      [providerId]: {
-        text: previous[providerId]?.text ?? blankTemplate,
-        media: previous[providerId]?.media ?? blankTemplate,
-        [kind]: { ...(previous[providerId]?.[kind] ?? blankTemplate), [field]: value },
-      },
-    }))
-  }
-
-  const credentialsDone = Boolean(
-    provider?.credentials
-      .filter((field) => field.required)
-      .every((field) => (credentials[providerId]?.[field.key] ?? '').trim())
-  )
-
-  const stepList = [
-    { step: 1, key: 'provider', done: Boolean(providerId && from.trim()) },
-    { step: 2, key: 'credentials', done: credentialsDone },
-    { step: 3, key: 'webhook', done: Boolean(initial.webhookSeenAt) },
-    { step: 4, key: 'templates', done: Boolean(template.name || mediaTemplate.name) },
-    { step: 5, key: 'test', done: initial.hasMessages },
-  ] as const
-  const lastStep = stepList.length
-
-  const setCredential = (field: string, value: string) => {
-    setMissing((previous) => previous.filter((key) => key !== field))
-    setCredentials((previous) => ({
-      ...previous,
-      [providerId]: { ...(previous[providerId] ?? {}), [field]: value },
-    }))
-  }
-
-  // Which secrets already exist on the server, so an emptied field knows
-  // whether putting the mask back means "keep what is stored" or nothing.
-  const storedSecrets = useRef(
-    new Set(
-      Object.entries(initial.credentials).flatMap(([provider, fields]) =>
-        Object.entries(fields)
-          .filter(([, value]) => value === SECRET_MASK)
-          .map(([field]) => `${provider}.${field}`)
-      )
-    )
-  )
-
-  const save = (then?: () => void) => {
-    if (!provider) return
-    startTransition(async () => {
-      const result = await saveWhatsappSettings({
-        enabled,
-        provider: provider.id,
-        from,
-        templateName: template.name,
-        templateLanguage: template.language,
-        templateVariables: template.variables,
-        mediaTemplateName: mediaTemplate.name,
-        mediaTemplateLanguage: mediaTemplate.language,
-        mediaTemplateVariables: mediaTemplate.variables,
-        credentials: credentials[provider.id] ?? {},
-      })
-
-      if (!result.success) {
-        toast.error(result.error ?? t('saveError'))
-        return
-      }
-
-      const savedWebhookUrl = result.data?.webhookUrl
-      if (savedWebhookUrl) {
-        setWebhookUrls((previous) => ({ ...previous, [provider.id]: savedWebhookUrl }))
-      }
-
-      // Half-finished is a normal state here, so it is reported rather than
-      // refused, and marked on the fields it concerns.
-      const stillNeeded = result.data?.missing ?? []
-      setMissing(
-        provider.credentials
-          .filter((field) => stillNeeded.includes(field.label))
-          .map((field) => field.key)
-      )
-      if (stillNeeded.length > 0) {
-        toast.warning(t('savedIncomplete', { fields: stillNeeded.join(', ') }))
-      } else {
-        toast.success(t('saved'))
-      }
-
-      then?.()
-      router.refresh()
-    })
-  }
-
-  const copyWebhook = () => {
-    if (!webhookUrl) return
-    navigator.clipboard.writeText(webhookUrl)
-    setCopied(true)
-    toast.success(t('webhook.copied'))
-    setTimeout(() => setCopied(false), 2000)
-  }
-
-  const sendTest = () => {
-    if (!testNumber.trim()) return
-    startTransition(async () => {
-      const result = await sendWhatsappTestMessage(testNumber.trim())
-      if (result.success) {
-        toast.success(t('test.sent'))
-        router.refresh()
-      } else {
-        toast.error(result.error ?? t('test.failed'))
-      }
-    })
-  }
-
-  const register = () => {
-    startTransition(async () => {
-      const result = await registerWhatsappNumber(registrationPin.trim())
-      if (result.success) {
-        toast.success(t('register.done'))
-        setRegistrationPin('')
-      } else {
-        toast.error(result.error ?? t('register.failed'))
-      }
-    })
-  }
-
-  const openProvider = (href: string, label: string) => (
-    <a
-      href={href}
-      target="_blank"
-      rel="noopener noreferrer"
-      className="inline-flex items-center gap-1 text-sm font-medium text-primary underline underline-offset-4 hover:no-underline"
-    >
-      {label}
-      <ExternalLink className="h-3 w-3" />
-    </a>
-  )
-
-  /** Saves, then moves on. Steps that only read data skip the save. */
-  const continueButton = (options?: { save?: boolean }) => (
-    <Button
-      type="button"
-      onClick={() =>
-        options?.save === false
-          ? setCurrent((step) => Math.min(step + 1, lastStep))
-          : save(() => setCurrent((step) => Math.min(step + 1, lastStep)))
-      }
-      disabled={isPending}
-    >
-      {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-      {t('steps.continue')}
-      <ArrowRight className="ml-1.5 h-4 w-4" />
-    </Button>
-  )
-
-  const step = stepList.find((entry) => entry.step === current)
-
-  return (
-    <div className="space-y-6">
-      <ReadOnlyBanner />
-      <ReadOnlyWrapper>
-        <AppCard
-          title={t('title')}
-          description={t('description')}
-          action={<DocsLink href="/docs/integrations/whatsapp" variant="header" />}
-          contentClassName="space-y-6"
-        >
-          <div className="flex items-center justify-between">
-            <div className="space-y-0.5">
-              <Label htmlFor="enable-whatsapp">{t('enable.label')}</Label>
-              <p className="text-xs text-muted-foreground">{t('enable.hint')}</p>
-            </div>
-            <Switch id="enable-whatsapp" checked={enabled} onCheckedChange={setEnabled} />
-          </div>
-
-          {!enabled ? (
-            <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
-              <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
-              <p className="text-sm text-muted-foreground">{t('enable.disabledInfo')}</p>
-            </div>
-          ) : (
-            <>
-              <Stepper value={current} onValueChange={setCurrent}>
-                {stepList.map((entry) => (
-                  <StepperItem
-                    key={entry.step}
-                    step={entry.step}
-                    completed={entry.done}
-                    attention={!entry.done}
-                  >
-                    <StepperTrigger>
-                      <StepperIndicator>
-                        {entry.done ? <Check className="h-4 w-4" /> : entry.step}
-                      </StepperIndicator>
-                      <StepperTitle
-                        className={
-                          entry.done
-                            ? 'hidden lg:block'
-                            : 'hidden font-medium text-amber-700 lg:block dark:text-amber-300'
-                        }
-                      >
-                        {t(`steps.${entry.key}.title`)}
-                      </StepperTitle>
-                    </StepperTrigger>
-                    {entry.step < lastStep && <StepperSeparator />}
-                  </StepperItem>
-                ))}
-              </Stepper>
-
-              <div className="rounded-lg border p-4">
-                <div className="space-y-1">
-                  <p className="text-sm font-medium">{t(`steps.${step?.key}.title`)}</p>
-                  <p className="text-xs text-muted-foreground">
-                    {current === 2 || current === 3
-                      ? t(`steps.${step?.key}.description`, { provider: provider?.label ?? '' })
-                      : t(`steps.${step?.key}.description`)}
-                  </p>
-                </div>
-
-                <div className="mt-4 space-y-4">
-                  {current === 1 && (
-                    <div className="space-y-4">
-                      {/* A row rather than a dropdown, the way the SMS page
-                          offers its providers: two choices are worth showing,
-                          not hiding behind a click. */}
-                      <div className="space-y-2">
-                        <Label>{t('provider.label')}</Label>
-                        <div className="flex flex-wrap gap-2">
-                          {initial.providers.map((option) => (
-                            <Button
-                              key={option.id}
-                              type="button"
-                              variant={providerId === option.id ? 'default' : 'outline'}
-                              onClick={() => setProviderId(option.id)}
-                              className="flex-1"
-                            >
-                              {option.label}
-                            </Button>
-                          ))}
-                        </div>
-                        <p className="text-xs text-muted-foreground">{t('provider.hint')}</p>
-                      </div>
-
-                      <div className="space-y-2 md:max-w-sm">
-                        <Label htmlFor="whatsapp-from">{t('from.label')}</Label>
-                        <Input
-                          id="whatsapp-from"
-                          name="whatsapp-from"
-                          value={from}
-                          onChange={(event) => setFrom(event.target.value)}
-                          placeholder="+49 151 12345678"
-                          autoComplete="off"
-                          data-1p-ignore
-                          data-lpignore="true"
-                        />
-                        <p className="text-xs text-muted-foreground">{t('from.hint')}</p>
-                      </div>
-                    </div>
-                  )}
-
-                  {/* Meta's pages hang off an app id we never collect, so the
-                      button below reaches the app list and no further. The
-                      rest of the way is written out. */}
-                  {current === 2 && provider?.setup.credentialsPath && (
-                    <p className="rounded-lg bg-muted/40 p-3 text-xs text-muted-foreground">
-                      {t('steps.consolePath')}{' '}
-                      <span className="font-medium text-foreground">
-                        {provider.setup.credentialsPath}
-                      </span>
-                    </p>
-                  )}
-
-                  {current === 2 && provider && (
-                    <div className="grid gap-4 md:grid-cols-2">
-                      {provider.credentials.map((field) => {
-                        const value = credentials[provider.id]?.[field.key] ?? ''
-                        const isRevealed = revealed[field.key] ?? false
-                        const isMissing = missing.includes(field.key)
-                        // A saved secret is only ever a mask here: the real
-                        // value stays on the server, so there is nothing an
-                        // eye could reveal until a new one is typed.
-                        const isStoredSecret = field.secret && value === SECRET_MASK
-                        return (
-                          <div key={field.key} className="space-y-2">
-                            <Label htmlFor={`whatsapp-${field.key}`}>
-                              {field.label}
-                              {field.required && ' *'}
-                            </Label>
-                            <div className="flex items-center gap-2">
-                              <Input
-                                id={`whatsapp-${field.key}`}
-                                name={`whatsapp-${field.key}`}
-                                // Never type="password": see .masked-value.
-                                type="text"
-                                value={value}
-                                placeholder={field.placeholder}
-                                // These are provider credentials, not the
-                                // user's own login: a masked field otherwise
-                                // invites a saved email into the one above it.
-                                autoComplete={field.secret ? 'new-password' : 'off'}
-                                data-1p-ignore
-                                data-lpignore="true"
-                                aria-invalid={isMissing}
-                                className={cn(
-                                  field.secret && !isRevealed && !isStoredSecret && 'masked-value',
-                                  isMissing && 'border-amber-500'
-                                )}
-                                onChange={(event) => setCredential(field.key, event.target.value)}
-                                // Clicking into a saved secret empties it, so
-                                // a paste cannot land after the mask and be
-                                // saved as mask plus token. Leaving without
-                                // typing puts the mask back and keeps the
-                                // stored value.
-                                onFocus={() => {
-                                  if (isStoredSecret) setCredential(field.key, '')
-                                }}
-                                onBlur={() => {
-                                  if (
-                                    value === '' &&
-                                    storedSecrets.current.has(`${providerId}.${field.key}`)
-                                  ) {
-                                    setCredential(field.key, SECRET_MASK)
-                                  }
-                                }}
-                              />
-                              {field.secret && !isStoredSecret && (
-                                <Button
-                                  type="button"
-                                  variant="outline"
-                                  size="icon"
-                                  onClick={() =>
-                                    setRevealed((previous) => ({
-                                      ...previous,
-                                      [field.key]: !isRevealed,
-                                    }))
-                                  }
-                                  aria-label={isRevealed ? t('secret.hide') : t('secret.show')}
-                                >
-                                  {isRevealed ? (
-                                    <EyeOff className="h-4 w-4" />
-                                  ) : (
-                                    <Eye className="h-4 w-4" />
-                                  )}
-                                </Button>
-                              )}
-                            </div>
-                            {isMissing ? (
-                              <p className="text-xs font-medium text-amber-700 dark:text-amber-400">
-                                {t('fieldStillNeeded')}
-                              </p>
-                            ) : isStoredSecret ? (
-                              <p className="text-xs text-muted-foreground">{t('secret.stored')}</p>
-                            ) : (
-                              field.help && (
-                                <p className="text-xs text-muted-foreground">{field.help}</p>
-                              )
-                            )}
-                          </div>
-                        )
-                      })}
-                    </div>
-                  )}
-
-                  {current === 2 && provider?.supportsRegistration && (
-                    <div className="space-y-2 rounded-lg border border-dashed p-4">
-                      <p className="text-sm font-medium">{t('register.title')}</p>
-                      <p className="text-xs text-muted-foreground">{t('register.description')}</p>
-                      <div className="flex flex-wrap items-center gap-2">
-                        <Input
-                          value={registrationPin}
-                          onChange={(event) => setRegistrationPin(event.target.value)}
-                          placeholder="123456"
-                          inputMode="numeric"
-                          autoComplete="off"
-                          data-1p-ignore
-                          data-lpignore="true"
-                          maxLength={6}
-                          className="w-32 font-mono"
-                        />
-                        <Button
-                          type="button"
-                          variant="outline"
-                          onClick={register}
-                          disabled={isPending || registrationPin.trim().length !== 6}
-                        >
-                          {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                          {t('register.action')}
-                        </Button>
-                      </div>
-                      <p className="text-xs text-amber-600">{t('register.limit')}</p>
-                    </div>
-                  )}
-
-                  {current === 3 && (
-                    <>
-                      {provider?.setup.webhookPath && (
-                        <p className="rounded-lg bg-muted/40 p-3 text-xs text-muted-foreground">
-                          {t('steps.consolePath')}{' '}
-                          <span className="font-medium text-foreground">
-                            {provider.setup.webhookPath}
-                          </span>
-                        </p>
-                      )}
-                      <div className="space-y-2">
-                        <Label>{t('webhook.label')}</Label>
-                        <div className="flex items-center gap-2">
-                          <Input readOnly value={webhookUrl} className="font-mono text-xs" />
-                          <Button
-                            type="button"
-                            variant="outline"
-                            size="icon"
-                            onClick={copyWebhook}
-                            aria-label={t('webhook.copy')}
-                          >
-                            {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
-                          </Button>
-                        </div>
-                        <p className="text-xs text-muted-foreground">{t('webhook.description')}</p>
-                        <p className="text-xs text-muted-foreground">{t('webhook.saveFirst')}</p>
-                      </div>
-
-                      <div
-                        className={
-                          initial.webhookSeenAt
-                            ? 'rounded-lg border border-dashed bg-muted/30 p-3 text-xs text-muted-foreground'
-                            : 'rounded-lg border border-dashed border-amber-500/60 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-400'
-                        }
-                      >
-                        {initial.webhookSeenAt
-                          ? t('steps.webhook.seen')
-                          : t('steps.webhook.waiting')}
-                      </div>
-                    </>
-                  )}
-
-                  {current === 4 && (
-                    <>
-                      <div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
-                        <Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
-                        <div className="space-y-2 text-sm text-muted-foreground">
-                          <p>{t('template.windowExplainer')}</p>
-                          <p>{t('template.whatIsIt', { provider: provider?.label ?? '' })}</p>
-                          <p>{t('template.whyTwo')}</p>
-                          <DocsLink href="/docs/integrations/whatsapp" variant="hint" />
-                        </div>
-                      </div>
-
-                      <TemplateSetupFields
-                        kind="text"
-                        provider={provider}
-                        name={template.name}
-                        onName={(value) => setTemplate('text', 'name', value)}
-                        language={template.language}
-                        onLanguage={(value) => setTemplate('text', 'language', value)}
-                        variables={template.variables}
-                        onVariables={(value) => setTemplate('text', 'variables', value)}
-                      />
-
-                      <TemplateSetupFields
-                        kind="media"
-                        provider={provider}
-                        name={mediaTemplate.name}
-                        onName={(value) => setTemplate('media', 'name', value)}
-                        language={mediaTemplate.language}
-                        onLanguage={(value) => setTemplate('media', 'language', value)}
-                        variables={mediaTemplate.variables}
-                        onVariables={(value) => setTemplate('media', 'variables', value)}
-                        mediaUrlPrefix={initial.mediaUrlPrefix}
-                      />
-                    </>
-                  )}
-
-                  {current === 5 && (
-                    <>
-                      <Input
-                        value={testNumber}
-                        onChange={(event) => setTestNumber(event.target.value)}
-                        placeholder="+49 151 12345678"
-                        autoComplete="off"
-                        data-1p-ignore
-                        data-lpignore="true"
-                      />
-                      <p className="text-xs text-muted-foreground">{t('test.hint')}</p>
-                    </>
-                  )}
-                </div>
-
-                <div className="mt-6 flex flex-wrap items-center justify-between gap-2 border-t pt-4">
-                  <Button
-                    type="button"
-                    variant="ghost"
-                    onClick={() => setCurrent((value) => Math.max(value - 1, 1))}
-                    disabled={current === 1}
-                  >
-                    <ArrowLeft className="mr-1.5 h-4 w-4" />
-                    {t('steps.back')}
-                  </Button>
-
-                  <div className="flex flex-wrap items-center gap-2">
-                    {/* This step asks for the sending number, which on some
-                        providers is a sender set up in their console. */}
-                    {current === 1 &&
-                      provider?.setup.number &&
-                      openProvider(provider.setup.number, t('steps.openNumber'))}
-                    {current === 2 && provider && (
-                      <>
-                        {openProvider(
-                          provider.setup.credentials,
-                          t('steps.openProvider', { provider: provider.label })
-                        )}
-                        {/* The token that lasts is minted somewhere else
-                            entirely, which is where this step stalls. */}
-                        {provider.setup.token &&
-                          openProvider(provider.setup.token, t('steps.openToken'))}
-                      </>
-                    )}
-                    {current === 3 && provider && (
-                      <>
-                        {openProvider(
-                          provider.setup.webhook,
-                          t('steps.webhook.open', { provider: provider.label })
-                        )}
-                        <Button variant="ghost" size="sm" onClick={() => router.refresh()}>
-                          <RefreshCw className="mr-1.5 h-3 w-3" />
-                          {t('steps.webhook.recheck')}
-                        </Button>
-                      </>
-                    )}
-                    {current === 4 &&
-                      provider &&
-                      openProvider(
-                        provider.setup.templates,
-                        t('steps.templates.open', { provider: provider.label })
-                      )}
-
-                    {current === 5 ? (
-                      <>
-                        <Button
-                          type="button"
-                          onClick={sendTest}
-                          disabled={isPending || !testNumber.trim() || !credentialsDone}
-                        >
-                          {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                          {t('test.send')}
-                        </Button>
-                        <SaveButton>
-                          <Button
-                            type="button"
-                            variant="outline"
-                            onClick={() => save()}
-                            disabled={isPending}
-                          >
-                            {t('save')}
-                          </Button>
-                        </SaveButton>
-                      </>
-                    ) : (
-                      <SaveButton>{continueButton({ save: current !== 3 })}</SaveButton>
-                    )}
-                  </div>
-                </div>
-              </div>
-            </>
-          )}
-        </AppCard>
-
-        {initial.enabled && (
-          <AppCard title={t('disconnect.title')} description={t('disconnect.hint')}>
-            <Button
-              type="button"
-              variant="destructive"
-              onClick={() =>
-                startTransition(async () => {
-                  const result = await disconnectWhatsapp()
-                  if (result.success) {
-                    toast.success(t('disconnect.done'))
-                    router.refresh()
-                  } else {
-                    toast.error(result.error ?? t('saveError'))
-                  }
-                })
-              }
-              disabled={isPending}
-            >
-              {t('disconnect.action')}
-            </Button>
-          </AppCard>
-        )}
-      </ReadOnlyWrapper>
-    </div>
-  )
-}

+ 3 - 0
src/integrations/amazon-ses/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('amazon-ses')

+ 27 - 0
src/integrations/amazon-ses/server.ts

@@ -0,0 +1,27 @@
+import { sendTestEmail } from '../messaging/email-test'
+import { messagingConnector, requireFields } from '../messaging/factory'
+import { manifest } from './manifest'
+
+/**
+ * Checking SES keys means a SigV4-signed request, which is more machinery
+ * than a settings save should carry. The fields are checked here and the keys
+ * are proven by the test email on the connection page.
+ */
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(
+      credentials,
+      ['accessKeyId', 'secretAccessKey', 'region'],
+      'Amazon SES'
+    )
+    return missing ?? { ok: true }
+  },
+  {
+    identify: async ({ credentials }) => ({
+      id: credentials.accessKeyId,
+      name: `${credentials.accessKeyId} (${credentials.region})`,
+    }),
+    sendTest: sendTestEmail,
+  }
+)

+ 3 - 0
src/integrations/mailgun/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('mailgun')

+ 41 - 0
src/integrations/mailgun/server.ts

@@ -0,0 +1,41 @@
+import { sendTestEmail } from '../messaging/email-test'
+import { messagingConnector, requireFields } from '../messaging/factory'
+import { manifest } from './manifest'
+
+/**
+ * Mailgun authenticates as the user `api`, and its domain endpoint answers
+ * only for the region the domain lives in, so the region setting is part of
+ * what is being checked here.
+ */
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials, settings }) => {
+    const missing = requireFields(credentials, ['apiKey', 'domain'], 'Mailgun')
+    if (missing) return missing
+    const host = settings.region === 'eu' ? 'api.eu.mailgun.net' : 'api.mailgun.net'
+    const auth = Buffer.from(`api:${credentials.apiKey}`).toString('base64')
+    try {
+      const res = await fetch(
+        `https://${host}/v3/domains/${encodeURIComponent(credentials.domain)}`,
+        {
+          headers: { Authorization: `Basic ${auth}` },
+        }
+      )
+      if (res.ok) return { ok: true }
+      if (res.status === 404) {
+        return {
+          ok: false,
+          message: `Mailgun does not have ${credentials.domain} in this region.`,
+        }
+      }
+      return { ok: false, message: `Mailgun rejected the key (${res.status}).` }
+    } catch (err) {
+      return { ok: false, message: `Could not reach Mailgun: ${(err as Error).message}` }
+    }
+  },
+  {
+    /** The sending domain is what a Mailgun key is scoped to. */
+    identify: async ({ credentials }) => ({ id: credentials.domain, name: credentials.domain }),
+    sendTest: sendTestEmail,
+  }
+)

+ 469 - 0
src/integrations/messaging/catalog.ts

@@ -0,0 +1,469 @@
+/**
+ * Every messaging vendor a workshop can send through, declared once.
+ *
+ * SMS, WhatsApp, Telegram and email each used to have their own settings page
+ * writing their own `AppSetting` rows. They are integrations like any other,
+ * so they live in the catalog now — but a workshop that configured Twilio two
+ * years ago must not be asked to do it again. That is what the `legacy` field
+ * on every credential and setting is for: it names the row the value used to
+ * live in, so the platform can adopt an existing setup into a connection
+ * without anyone touching a form.
+ *
+ * Credential field lists are written out here rather than imported from the
+ * WhatsApp adapters, because manifests are serialized to the browser for the
+ * catalog and must stay free of server code. A test keeps the two in step.
+ */
+
+import { ORG_EMAIL_KEYS } from '@/features/email/Schema/emailSettingsSchema'
+import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
+import { ORG_TELEGRAM_KEYS } from '@/features/telegram/Schema/telegramSettingsSchema'
+import {
+  ORG_WHATSAPP_KEYS,
+  WHATSAPP_WEBHOOK_TOKEN_FIELD,
+  whatsappCredentialKey,
+  whatsappTemplateKey,
+} from '@/features/whatsapp/Schema/whatsappSettingsSchema'
+import type { CredentialField, SettingField } from '@/features/integrations/Lib/types'
+
+export type MessagingChannel = 'sms' | 'whatsapp' | 'telegram' | 'email'
+
+/** A credential field plus the settings row it used to be stored in. */
+export interface MessagingCredential extends CredentialField {
+  /** Key under `AppSetting` before the move. Absent for values we generate. */
+  legacy?: string
+  /**
+   * Generated rather than typed: a webhook secret the workshop never sees.
+   * Adoption carries the old value over so the vendor's existing webhook URL
+   * keeps validating.
+   */
+  generated?: boolean
+}
+
+export interface MessagingSetting extends SettingField {
+  legacy?: string
+}
+
+export interface MessagingProvider {
+  /** Connector id, which is also the folder name under src/integrations. */
+  id: string
+  /** Vendor name as the vendor writes it. */
+  name: string
+  channel: MessagingChannel
+  /**
+   * The value this vendor had in `sms.provider`, `whatsapp.provider` or
+   * `email.provider`. Telegram had no provider row: the bot token being set
+   * was the whole signal.
+   */
+  legacyProvider: string | null
+  /**
+   * Row that had to be set for the channel to count as configured. Adoption
+   * looks here first, so an org that half-filled a form is not adopted.
+   */
+  legacyEvidence: string
+  countries: string[] | 'global'
+  capabilities: string[]
+  credentials: MessagingCredential[]
+  settings: MessagingSetting[]
+}
+
+function secret(
+  key: string,
+  legacy: string,
+  extra: Partial<MessagingCredential> = {}
+): MessagingCredential {
+  return { key, label: key, type: 'password', required: true, legacy, ...extra }
+}
+
+function text(
+  key: string,
+  legacy: string,
+  extra: Partial<MessagingCredential> = {}
+): MessagingCredential {
+  return { key, label: key, type: 'text', required: true, legacy, ...extra }
+}
+
+/** The number or address the channel sends from, which every vendor needs. */
+function fromNumber(legacy: string): MessagingSetting {
+  return { key: 'phoneNumber', type: 'text', label: 'phoneNumber', required: true, legacy }
+}
+
+function fromAddress(emailLegacy: string, nameLegacy: string): MessagingSetting[] {
+  return [
+    { key: 'fromEmail', type: 'text', label: 'fromEmail', required: true, legacy: emailLegacy },
+    { key: 'fromName', type: 'text', label: 'fromName', legacy: nameLegacy },
+  ]
+}
+
+const SMS: MessagingProvider[] = [
+  {
+    id: 'twilio-sms',
+    name: 'Twilio',
+    channel: 'sms',
+    legacyProvider: 'twilio',
+    legacyEvidence: ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID,
+    countries: 'global',
+    capabilities: ['sms.send', 'sms.receive'],
+    credentials: [
+      text('accountSid', ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID, { placeholder: 'ACxxxxxxxx' }),
+      secret('authToken', ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN),
+      secret('webhookSecret', ORG_SMS_KEYS.SMS_WEBHOOK_SECRET, {
+        required: false,
+        generated: true,
+      }),
+    ],
+    settings: [fromNumber(ORG_SMS_KEYS.SMS_PHONE_NUMBER)],
+  },
+  {
+    id: 'vonage-sms',
+    name: 'Vonage',
+    channel: 'sms',
+    legacyProvider: 'vonage',
+    legacyEvidence: ORG_SMS_KEYS.SMS_VONAGE_API_KEY,
+    countries: 'global',
+    capabilities: ['sms.send', 'sms.receive'],
+    credentials: [
+      text('apiKey', ORG_SMS_KEYS.SMS_VONAGE_API_KEY),
+      secret('apiSecret', ORG_SMS_KEYS.SMS_VONAGE_API_SECRET),
+      secret('webhookSecret', ORG_SMS_KEYS.SMS_WEBHOOK_SECRET, {
+        required: false,
+        generated: true,
+      }),
+    ],
+    settings: [fromNumber(ORG_SMS_KEYS.SMS_PHONE_NUMBER)],
+  },
+  {
+    id: 'telnyx-sms',
+    name: 'Telnyx',
+    channel: 'sms',
+    legacyProvider: 'telnyx',
+    legacyEvidence: ORG_SMS_KEYS.SMS_TELNYX_API_KEY,
+    countries: 'global',
+    capabilities: ['sms.send', 'sms.receive'],
+    credentials: [
+      secret('apiKey', ORG_SMS_KEYS.SMS_TELNYX_API_KEY),
+      secret('webhookSecret', ORG_SMS_KEYS.SMS_WEBHOOK_SECRET, {
+        required: false,
+        generated: true,
+      }),
+    ],
+    settings: [fromNumber(ORG_SMS_KEYS.SMS_PHONE_NUMBER)],
+  },
+]
+
+/**
+ * The switch the old WhatsApp page had. A connection being active is not the
+ * same thing: a workshop can keep its credentials in place while turning
+ * outbound WhatsApp off for a while.
+ */
+function whatsappEnabled(): MessagingSetting {
+  return {
+    key: 'enabled',
+    type: 'boolean',
+    label: 'enabled',
+    legacy: ORG_WHATSAPP_KEYS.WHATSAPP_ENABLED,
+    default: true,
+  }
+}
+
+/** Template settings, which WhatsApp needs and no other channel has. */
+function whatsappTemplateSettings(provider: string): MessagingSetting[] {
+  return [
+    {
+      key: 'templateName',
+      type: 'text',
+      label: 'templateName',
+      legacy: whatsappTemplateKey(provider, 'text', 'name'),
+    },
+    {
+      key: 'templateLanguage',
+      type: 'text',
+      label: 'templateLanguage',
+      legacy: whatsappTemplateKey(provider, 'text', 'language'),
+    },
+    {
+      key: 'templateVariables',
+      type: 'text',
+      label: 'templateVariables',
+      legacy: whatsappTemplateKey(provider, 'text', 'variables'),
+    },
+    {
+      key: 'mediaTemplateName',
+      type: 'text',
+      label: 'mediaTemplateName',
+      legacy: whatsappTemplateKey(provider, 'media', 'name'),
+    },
+    {
+      key: 'mediaTemplateLanguage',
+      type: 'text',
+      label: 'mediaTemplateLanguage',
+      legacy: whatsappTemplateKey(provider, 'media', 'language'),
+    },
+    {
+      key: 'mediaTemplateVariables',
+      type: 'text',
+      label: 'mediaTemplateVariables',
+      legacy: whatsappTemplateKey(provider, 'media', 'variables'),
+    },
+  ]
+}
+
+const WHATSAPP: MessagingProvider[] = [
+  {
+    id: 'whatsapp-meta',
+    name: 'WhatsApp Business (Meta)',
+    channel: 'whatsapp',
+    legacyProvider: 'meta',
+    legacyEvidence: whatsappCredentialKey('meta', 'phoneNumberId'),
+    countries: 'global',
+    capabilities: ['whatsapp.send', 'whatsapp.receive'],
+    credentials: [
+      text('phoneNumberId', whatsappCredentialKey('meta', 'phoneNumberId'), {
+        placeholder: '123456789012345',
+      }),
+      secret('accessToken', whatsappCredentialKey('meta', 'accessToken')),
+      text('verifyToken', whatsappCredentialKey('meta', 'verifyToken')),
+      secret('appSecret', whatsappCredentialKey('meta', 'appSecret'), { required: false }),
+      text('apiVersion', whatsappCredentialKey('meta', 'apiVersion'), { required: false }),
+    ],
+    settings: [
+      whatsappEnabled(),
+      fromNumber(ORG_WHATSAPP_KEYS.WHATSAPP_FROM),
+      ...whatsappTemplateSettings('meta'),
+    ],
+  },
+  {
+    id: 'whatsapp-twilio',
+    name: 'WhatsApp via Twilio',
+    channel: 'whatsapp',
+    legacyProvider: 'twilio',
+    legacyEvidence: whatsappCredentialKey('twilio', 'accountSid'),
+    countries: 'global',
+    capabilities: ['whatsapp.send', 'whatsapp.receive'],
+    credentials: [
+      text('accountSid', whatsappCredentialKey('twilio', 'accountSid'), {
+        placeholder: 'ACxxxxxxxx',
+      }),
+      secret('authToken', whatsappCredentialKey('twilio', 'authToken')),
+      text('messagingServiceSid', whatsappCredentialKey('twilio', 'messagingServiceSid'), {
+        required: false,
+        placeholder: 'MGxxxxxxxx',
+      }),
+      secret(
+        WHATSAPP_WEBHOOK_TOKEN_FIELD,
+        whatsappCredentialKey('twilio', WHATSAPP_WEBHOOK_TOKEN_FIELD),
+        { required: false, generated: true }
+      ),
+    ],
+    settings: [
+      whatsappEnabled(),
+      fromNumber(ORG_WHATSAPP_KEYS.WHATSAPP_FROM),
+      ...whatsappTemplateSettings('twilio'),
+    ],
+  },
+]
+
+const TELEGRAM: MessagingProvider[] = [
+  {
+    id: 'telegram',
+    name: 'Telegram',
+    channel: 'telegram',
+    legacyProvider: null,
+    legacyEvidence: ORG_TELEGRAM_KEYS.TELEGRAM_BOT_TOKEN,
+    countries: 'global',
+    capabilities: ['telegram.send', 'telegram.receive'],
+    credentials: [
+      secret('botToken', ORG_TELEGRAM_KEYS.TELEGRAM_BOT_TOKEN),
+      secret('webhookSecret', ORG_TELEGRAM_KEYS.TELEGRAM_WEBHOOK_SECRET, {
+        required: false,
+        generated: true,
+      }),
+    ],
+    settings: [
+      {
+        key: 'enabled',
+        type: 'boolean',
+        label: 'enabled',
+        legacy: ORG_TELEGRAM_KEYS.TELEGRAM_ENABLED,
+        default: true,
+      },
+      {
+        key: 'botUsername',
+        type: 'text',
+        label: 'botUsername',
+        legacy: ORG_TELEGRAM_KEYS.TELEGRAM_BOT_USERNAME,
+      },
+    ],
+  },
+]
+
+const EMAIL: MessagingProvider[] = [
+  {
+    id: 'smtp',
+    name: 'SMTP',
+    channel: 'email',
+    legacyProvider: 'smtp',
+    legacyEvidence: ORG_EMAIL_KEYS.EMAIL_SMTP_HOST,
+    countries: 'global',
+    capabilities: ['email.send'],
+    credentials: [
+      text('host', ORG_EMAIL_KEYS.EMAIL_SMTP_HOST, { placeholder: 'smtp.example.com' }),
+      text('port', ORG_EMAIL_KEYS.EMAIL_SMTP_PORT, { placeholder: '587', default: '587' }),
+      text('user', ORG_EMAIL_KEYS.EMAIL_SMTP_USER, { required: false }),
+      secret('pass', ORG_EMAIL_KEYS.EMAIL_SMTP_PASS, { required: false }),
+    ],
+    settings: [
+      ...fromAddress(ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_EMAIL, ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_NAME),
+      {
+        key: 'secure',
+        type: 'boolean',
+        label: 'secure',
+        legacy: ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE,
+        default: false,
+      },
+      {
+        key: 'requireTls',
+        type: 'boolean',
+        label: 'requireTls',
+        legacy: ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS,
+        default: false,
+      },
+      {
+        key: 'rejectUnauthorized',
+        type: 'boolean',
+        label: 'rejectUnauthorized',
+        legacy: ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED,
+        default: true,
+      },
+    ],
+  },
+  {
+    id: 'resend',
+    name: 'Resend',
+    channel: 'email',
+    legacyProvider: 'resend',
+    legacyEvidence: ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY,
+    countries: 'global',
+    capabilities: ['email.send'],
+    credentials: [secret('apiKey', ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY, { placeholder: 're_...' })],
+    settings: fromAddress(
+      ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_EMAIL,
+      ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_NAME
+    ),
+  },
+  {
+    id: 'postmark',
+    name: 'Postmark',
+    channel: 'email',
+    legacyProvider: 'postmark',
+    legacyEvidence: ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY,
+    countries: 'global',
+    capabilities: ['email.send'],
+    credentials: [secret('apiKey', ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY)],
+    settings: fromAddress(
+      ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_EMAIL,
+      ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_NAME
+    ),
+  },
+  {
+    id: 'mailgun',
+    name: 'Mailgun',
+    channel: 'email',
+    legacyProvider: 'mailgun',
+    legacyEvidence: ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY,
+    countries: 'global',
+    capabilities: ['email.send'],
+    credentials: [
+      secret('apiKey', ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY),
+      text('domain', ORG_EMAIL_KEYS.EMAIL_MAILGUN_DOMAIN, { placeholder: 'mg.example.com' }),
+    ],
+    settings: [
+      ...fromAddress(
+        ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_EMAIL,
+        ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_NAME
+      ),
+      {
+        key: 'region',
+        type: 'select',
+        label: 'region',
+        legacy: ORG_EMAIL_KEYS.EMAIL_MAILGUN_REGION,
+        default: 'us',
+        options: [
+          { value: 'us', label: 'US' },
+          { value: 'eu', label: 'EU' },
+        ],
+      },
+    ],
+  },
+  {
+    id: 'sendgrid',
+    name: 'SendGrid',
+    channel: 'email',
+    legacyProvider: 'sendgrid',
+    legacyEvidence: ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY,
+    countries: 'global',
+    capabilities: ['email.send'],
+    credentials: [
+      secret('apiKey', ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY, { placeholder: 'SG...' }),
+    ],
+    settings: fromAddress(
+      ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_EMAIL,
+      ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_NAME
+    ),
+  },
+  {
+    id: 'amazon-ses',
+    name: 'Amazon SES',
+    channel: 'email',
+    legacyProvider: 'ses',
+    legacyEvidence: ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID,
+    countries: 'global',
+    capabilities: ['email.send'],
+    credentials: [
+      text('accessKeyId', ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID),
+      secret('secretAccessKey', ORG_EMAIL_KEYS.EMAIL_SES_SECRET_ACCESS_KEY),
+      // The old send path assumed us-east-1 when no region row existed.
+      text('region', ORG_EMAIL_KEYS.EMAIL_SES_REGION, {
+        placeholder: 'eu-west-1',
+        default: 'us-east-1',
+      }),
+    ],
+    settings: fromAddress(ORG_EMAIL_KEYS.EMAIL_SES_FROM_EMAIL, ORG_EMAIL_KEYS.EMAIL_SES_FROM_NAME),
+  },
+]
+
+export const MESSAGING_PROVIDERS: readonly MessagingProvider[] = [
+  ...SMS,
+  ...WHATSAPP,
+  ...TELEGRAM,
+  ...EMAIL,
+]
+
+const BY_ID = new Map(MESSAGING_PROVIDERS.map((p) => [p.id, p]))
+
+export function messagingProvider(connectorId: string): MessagingProvider | null {
+  return BY_ID.get(connectorId) ?? null
+}
+
+export function providersForChannel(channel: MessagingChannel): MessagingProvider[] {
+  return MESSAGING_PROVIDERS.filter((p) => p.channel === channel)
+}
+
+/** The connector a legacy `<channel>.provider` value points at. */
+export function providerForLegacyId(
+  channel: MessagingChannel,
+  legacyProvider: string | null
+): MessagingProvider | null {
+  const candidates = providersForChannel(channel)
+  if (channel === 'telegram') return candidates[0] ?? null
+  return candidates.find((p) => p.legacyProvider === legacyProvider) ?? null
+}
+
+/** Every legacy row the channel could have used, for a single settings read. */
+export function legacyKeysForChannel(channel: MessagingChannel): string[] {
+  const keys = new Set<string>()
+  for (const provider of providersForChannel(channel)) {
+    for (const c of provider.credentials) if (c.legacy) keys.add(c.legacy)
+    for (const s of provider.settings) if (s.legacy) keys.add(s.legacy)
+  }
+  return [...keys]
+}

+ 34 - 0
src/integrations/messaging/email-test.ts

@@ -0,0 +1,34 @@
+/**
+ * The test email every mail vendor sends from its connection page.
+ *
+ * A key check proves the key; only a delivered message proves the from
+ * address, the domain and the vendor's sending rules, which is what a
+ * workshop wants to know before an invoice goes out. It goes through the
+ * connection being looked at, not whichever vendor the organization is
+ * pointed at, so a vendor can be tried before it takes over.
+ */
+
+import { sendMailThroughConnection } from '@/lib/email'
+import type { MessagingSendTest } from './factory'
+
+export const sendTestEmail: MessagingSendTest = async (
+  { connectorId, credentials, settings },
+  to
+) => {
+  await sendMailThroughConnection(connectorId, credentials, settings, {
+    to: to.email,
+    subject: 'Email Test - Torqvoice',
+    html: `
+      <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
+        <h2>Email Configuration Test</h2>
+        <p>This is a test email from your organization's email integration.</p>
+        <p>If you're reading this, your email provider is configured correctly.</p>
+        <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
+        <p style="color: #6b7280; font-size: 12px;">
+          Sent to: ${to.email}<br/>
+          Time: ${new Date().toISOString()}
+        </p>
+      </div>
+    `,
+  })
+}

+ 156 - 0
src/integrations/messaging/factory.ts

@@ -0,0 +1,156 @@
+/**
+ * Turns a messaging catalog entry into a connector.
+ *
+ * Every messaging vendor has the same shape: keys pasted into a form, a
+ * from-address or number, and a cheap call that proves the keys work. Only
+ * that last call differs, so it is the only thing a vendor's folder writes.
+ *
+ * Generated credentials, such as the webhook secret behind an inbound SMS
+ * URL, are deliberately left out of the form: the workshop never types them,
+ * and dropping them from the manifest keeps them out of the browser.
+ */
+
+import type {
+  ConnectorContext,
+  ConnectorManifest,
+  ConnectorServer,
+  CredentialField,
+  SettingField,
+} from '@/features/integrations/Lib/types'
+import { type MessagingProvider, messagingProvider } from './catalog'
+
+export type MessagingVerify = (input: {
+  credentials: Record<string, string>
+  settings: Record<string, unknown>
+}) => Promise<{ ok: boolean; message?: string }>
+
+export type MessagingIdentify = (input: {
+  credentials: Record<string, string>
+  settings: Record<string, unknown>
+}) => Promise<{ id: string; name: string }>
+
+function credentialField(field: MessagingProvider['credentials'][number]): CredentialField {
+  const { legacy: _legacy, generated: _generated, ...rest } = field
+  return rest
+}
+
+function settingField(field: MessagingProvider['settings'][number]): SettingField {
+  const { legacy: _legacy, ...rest } = field
+  return rest
+}
+
+/** The plan feature each channel was gated on before it became a connector. */
+const CHANNEL_PLAN: Record<MessagingProvider['channel'], NonNullable<ConnectorManifest['plan']>> = {
+  sms: 'sms',
+  whatsapp: 'whatsapp',
+  telegram: 'telegram',
+  email: 'smtp',
+}
+
+export function messagingManifest(provider: MessagingProvider): ConnectorManifest {
+  return {
+    id: provider.id,
+    name: provider.name,
+    category: 'messaging',
+    plan: CHANNEL_PLAN[provider.channel],
+    countries: provider.countries,
+    logo: `/images/integrations/${provider.id}.svg`,
+    docs: `/docs/integrations/${provider.id}`,
+    auth: {
+      type: 'api-key',
+      fields: provider.credentials.filter((c) => !c.generated).map(credentialField),
+    },
+    capabilities: provider.capabilities,
+    settings: provider.settings.map(settingField),
+  }
+}
+
+/** Manifest for one catalog id. Missing means the id and the table disagree. */
+export function messagingManifestFor(id: string): ConnectorManifest {
+  const provider = messagingProvider(id)
+  if (!provider) throw new Error(`No messaging provider named ${id}`)
+  return messagingManifest(provider)
+}
+
+export type MessagingSendTest = (
+  input: {
+    connectorId: string
+    organizationId: string
+    credentials: Record<string, string>
+    settings: Record<string, unknown>
+  },
+  to: { email: string }
+) => Promise<void>
+
+export interface MessagingHooks {
+  /** Who the account is, shown as "Connected account" on the connection page. */
+  identify?: MessagingIdentify
+  /** A real message to the signed-in user, where a key check proves too little. */
+  sendTest?: MessagingSendTest
+}
+
+export function messagingConnector(
+  manifest: ConnectorManifest,
+  verify: MessagingVerify,
+  hooks: MessagingHooks = {}
+): ConnectorServer {
+  function input(ctx: ConnectorContext) {
+    return {
+      credentials: ctx.credentials as Record<string, string>,
+      settings: ctx.connection.settings,
+    }
+  }
+  const { identify, sendTest } = hooks
+
+  return {
+    manifest,
+    // Messaging is driven by the app sending a message, not by a timer or a
+    // subscription, so a messaging connector has no jobs of its own.
+    jobs: {},
+    test: (ctx) => verify(input(ctx)),
+    ...(identify ? { identify: (ctx: ConnectorContext) => identify(input(ctx)) } : {}),
+    ...(sendTest
+      ? {
+          sendTest: (ctx: ConnectorContext, to: { email: string }) =>
+            sendTest(
+              {
+                connectorId: ctx.connection.connectorId,
+                organizationId: ctx.connection.organizationId,
+                ...input(ctx),
+              },
+              to
+            ),
+        }
+      : {}),
+  }
+}
+
+/** Shared by the vendors that answer a plain authenticated GET. */
+export async function verifyByGet(
+  url: string,
+  headers: Record<string, string>,
+  vendor: string
+): Promise<{ ok: boolean; message?: string }> {
+  try {
+    const res = await fetch(url, { headers })
+    if (res.ok) return { ok: true }
+    const body = await res.text()
+    return {
+      ok: false,
+      message: `${vendor} rejected the credentials (${res.status}): ${body.slice(0, 200)}`,
+    }
+  } catch (err) {
+    return { ok: false, message: `Could not reach ${vendor}: ${(err as Error).message}` }
+  }
+}
+
+/** Every field the vendor cannot work without is present. */
+export function requireFields(
+  credentials: Record<string, string>,
+  keys: string[],
+  vendor: string
+): { ok: boolean; message?: string } | null {
+  const missing = keys.filter((k) => !credentials[k]?.trim())
+  if (missing.length === 0) return null
+  return { ok: false, message: `${vendor} needs ${missing.join(', ')}.` }
+}

+ 3 - 0
src/integrations/postmark/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('postmark')

+ 27 - 0
src/integrations/postmark/server.ts

@@ -0,0 +1,27 @@
+import { sendTestEmail } from '../messaging/email-test'
+import { messagingConnector, requireFields, verifyByGet } from '../messaging/factory'
+import { manifest } from './manifest'
+
+const SERVER_URL = 'https://api.postmarkapp.com/server'
+
+function headers(apiKey: string): Record<string, string> {
+  return { 'X-Postmark-Server-Token': apiKey, Accept: 'application/json' }
+}
+
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(credentials, ['apiKey'], 'Postmark')
+    if (missing) return missing
+    return verifyByGet(SERVER_URL, headers(credentials.apiKey), 'Postmark')
+  },
+  {
+    /** A Postmark token belongs to one named server. */
+    identify: async ({ credentials }) => {
+      const res = await fetch(SERVER_URL, { headers: headers(credentials.apiKey) })
+      const body = (await res.json().catch(() => ({}))) as { ID?: number; Name?: string }
+      return { id: String(body.ID ?? 'postmark'), name: body.Name ?? 'Postmark' }
+    },
+    sendTest: sendTestEmail,
+  }
+)

+ 24 - 0
src/integrations/registry.ts

@@ -8,8 +8,20 @@
  */
 
 import type { ConnectorManifest, ConnectorServer } from '@/features/integrations/Lib/types'
+import { manifest as amazonSes } from './amazon-ses/manifest'
 import { manifest as googleCalendar } from './google-calendar/manifest'
+import { manifest as mailgun } from './mailgun/manifest'
 import { manifest as microsoft365 } from './microsoft-365/manifest'
+import { manifest as postmark } from './postmark/manifest'
+import { manifest as resend } from './resend/manifest'
+import { manifest as sendgrid } from './sendgrid/manifest'
+import { manifest as smtp } from './smtp/manifest'
+import { manifest as telegram } from './telegram/manifest'
+import { manifest as telnyxSms } from './telnyx-sms/manifest'
+import { manifest as twilioSms } from './twilio-sms/manifest'
+import { manifest as vonageSms } from './vonage-sms/manifest'
+import { manifest as whatsappMeta } from './whatsapp-meta/manifest'
+import { manifest as whatsappTwilio } from './whatsapp-twilio/manifest'
 import { manifest as zoom } from './zoom/manifest'
 
 interface RegistryEntry {
@@ -21,6 +33,18 @@ const ENTRIES: readonly RegistryEntry[] = [
   { manifest: googleCalendar, load: () => import('./google-calendar/server') },
   { manifest: microsoft365, load: () => import('./microsoft-365/server') },
   { manifest: zoom, load: () => import('./zoom/server') },
+  { manifest: twilioSms, load: () => import('./twilio-sms/server') },
+  { manifest: vonageSms, load: () => import('./vonage-sms/server') },
+  { manifest: telnyxSms, load: () => import('./telnyx-sms/server') },
+  { manifest: whatsappMeta, load: () => import('./whatsapp-meta/server') },
+  { manifest: whatsappTwilio, load: () => import('./whatsapp-twilio/server') },
+  { manifest: telegram, load: () => import('./telegram/server') },
+  { manifest: smtp, load: () => import('./smtp/server') },
+  { manifest: resend, load: () => import('./resend/server') },
+  { manifest: postmark, load: () => import('./postmark/server') },
+  { manifest: mailgun, load: () => import('./mailgun/server') },
+  { manifest: sendgrid, load: () => import('./sendgrid/server') },
+  { manifest: amazonSes, load: () => import('./amazon-ses/server') },
 ]
 
 const BY_ID = new Map(ENTRIES.map((e) => [e.manifest.id, e]))

+ 3 - 0
src/integrations/resend/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('resend')

+ 30 - 0
src/integrations/resend/server.ts

@@ -0,0 +1,30 @@
+import { sendTestEmail } from '../messaging/email-test'
+import { messagingConnector, requireFields, verifyByGet } from '../messaging/factory'
+import { manifest } from './manifest'
+
+const DOMAINS_URL = 'https://api.resend.com/domains'
+
+function headers(apiKey: string): Record<string, string> {
+  return { Authorization: `Bearer ${apiKey}` }
+}
+
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(credentials, ['apiKey'], 'Resend')
+    if (missing) return missing
+    return verifyByGet(DOMAINS_URL, headers(credentials.apiKey), 'Resend')
+  },
+  {
+    /** Resend keys have no account name; the verified sending domain is the identity. */
+    identify: async ({ credentials }) => {
+      const res = await fetch(DOMAINS_URL, { headers: headers(credentials.apiKey) })
+      const body = (await res.json().catch(() => ({}))) as {
+        data?: { id?: string; name?: string }[]
+      }
+      const domain = body.data?.[0]
+      return { id: domain?.id ?? 'resend', name: domain?.name ?? 'Resend' }
+    },
+    sendTest: sendTestEmail,
+  }
+)

+ 3 - 0
src/integrations/sendgrid/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('sendgrid')

+ 35 - 0
src/integrations/sendgrid/server.ts

@@ -0,0 +1,35 @@
+import { sendTestEmail } from '../messaging/email-test'
+import { messagingConnector, requireFields, verifyByGet } from '../messaging/factory'
+import { manifest } from './manifest'
+
+function headers(apiKey: string): Record<string, string> {
+  return { Authorization: `Bearer ${apiKey}` }
+}
+
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(credentials, ['apiKey'], 'SendGrid')
+    if (missing) return missing
+    return verifyByGet(
+      'https://api.sendgrid.com/v3/scopes',
+      headers(credentials.apiKey),
+      'SendGrid'
+    )
+  },
+  {
+    /**
+     * The account's username, when the key is allowed to read the profile.
+     * A key scoped to sending only cannot, and then the vendor's name will do.
+     */
+    identify: async ({ credentials }) => {
+      const res = await fetch('https://api.sendgrid.com/v3/user/profile', {
+        headers: headers(credentials.apiKey),
+      })
+      const body = (await res.json().catch(() => ({}))) as { username?: string }
+      const name = res.ok && body.username ? body.username : 'SendGrid'
+      return { id: name, name }
+    },
+    sendTest: sendTestEmail,
+  }
+)

+ 3 - 0
src/integrations/smtp/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('smtp')

+ 30 - 0
src/integrations/smtp/server.ts

@@ -0,0 +1,30 @@
+import { sendTestEmail } from '../messaging/email-test'
+import { messagingConnector, requireFields } from '../messaging/factory'
+import { manifest } from './manifest'
+
+/**
+ * A live check would mean opening an SMTP session on every save, which is
+ * slow and blocked outbound on plenty of hosts. The connection page's "send a
+ * test email" is the real proof. The from address is not checked here: it is
+ * a setting, saved after the keys, and the settings form requires it.
+ */
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(credentials, ['host', 'port'], 'SMTP')
+    if (missing) return missing
+    if (!Number.isFinite(Number(credentials.port))) {
+      return { ok: false, message: 'The SMTP port must be a number.' }
+    }
+    return { ok: true }
+  },
+  {
+    identify: async ({ credentials }) => {
+      // The username is often an email address already, so it is shown next
+      // to the server rather than joined to it.
+      const name = credentials.user ? `${credentials.user} (${credentials.host})` : credentials.host
+      return { id: `${credentials.host}:${credentials.port}`, name }
+    },
+    sendTest: sendTestEmail,
+  }
+)

+ 3 - 0
src/integrations/telegram/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('telegram')

+ 70 - 0
src/integrations/telegram/server.ts

@@ -0,0 +1,70 @@
+import { deleteTelegramWebhook, setTelegramWebhook } from '@/lib/telegram'
+import { messagingConnector, requireFields } from '../messaging/factory'
+import { manifest } from './manifest'
+
+interface TelegramMe {
+  ok: boolean
+  result?: { id: number; username?: string; first_name?: string }
+  description?: string
+}
+
+async function getMe(botToken: string): Promise<TelegramMe> {
+  const res = await fetch(`https://api.telegram.org/bot${encodeURIComponent(botToken)}/getMe`)
+  return (await res.json()) as TelegramMe
+}
+
+const base = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(credentials, ['botToken'], 'Telegram')
+    if (missing) return missing
+    try {
+      const me = await getMe(credentials.botToken)
+      if (me.ok) return { ok: true }
+      return { ok: false, message: me.description ?? 'Telegram rejected the bot token.' }
+    } catch (err) {
+      return { ok: false, message: `Could not reach Telegram: ${(err as Error).message}` }
+    }
+  },
+  {
+    identify: async ({ credentials }) => {
+      const me = await getMe(credentials.botToken)
+      const username = me.result?.username
+      return {
+        id: String(me.result?.id ?? ''),
+        name: username ? `@${username}` : (me.result?.first_name ?? 'Telegram bot'),
+      }
+    },
+  }
+)
+
+export const connector = {
+  ...base,
+  /**
+   * Telegram only delivers messages to a URL the bot has registered, signed
+   * with the secret the platform minted for this connection. The bot's
+   * username is kept as a setting because invoices and the customer portal
+   * print a t.me link from it.
+   */
+  async onConnect(ctx) {
+    const botToken = String(ctx.credentials.botToken ?? '')
+    const secret = String(ctx.credentials.webhookSecret ?? '')
+    if (!botToken || !secret) throw new Error('Telegram connection is missing its keys')
+
+    // The secret travels in the header Telegram signs with, so it has no
+    // business in the URL, where it would end up in every access log.
+    const url = `${ctx.appUrl}/api/webhooks/telegram/${ctx.connection.organizationId}`
+    await setTelegramWebhook(botToken, url, secret)
+
+    // Only overwrite the username with an answer; a rate-limited getMe must
+    // not blank out one the workshop already had.
+    const me = await getMe(botToken)
+    const username = me.result?.username
+    return username ? { settings: { botUsername: username } } : undefined
+  },
+  async onDisconnect(ctx) {
+    const botToken = String(ctx.credentials.botToken ?? '')
+    if (!botToken) return
+    await deleteTelegramWebhook(botToken)
+  },
+} satisfies typeof base

+ 3 - 0
src/integrations/telnyx-sms/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('telnyx-sms')

+ 12 - 0
src/integrations/telnyx-sms/server.ts

@@ -0,0 +1,12 @@
+import { messagingConnector, requireFields, verifyByGet } from '../messaging/factory'
+import { manifest } from './manifest'
+
+export const connector = messagingConnector(manifest, async ({ credentials }) => {
+  const missing = requireFields(credentials, ['apiKey'], 'Telnyx')
+  if (missing) return missing
+  return verifyByGet(
+    'https://api.telnyx.com/v2/messaging_profiles?page[size]=1',
+    { Authorization: `Bearer ${credentials.apiKey}` },
+    'Telnyx'
+  )
+})

+ 3 - 0
src/integrations/twilio-sms/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('twilio-sms')

+ 30 - 0
src/integrations/twilio-sms/server.ts

@@ -0,0 +1,30 @@
+import { messagingConnector, requireFields, verifyByGet } from '../messaging/factory'
+import { manifest } from './manifest'
+
+/** Twilio's own account resource: the cheapest call that proves a key pair. */
+function accountUrl(accountSid: string): string {
+  return `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(accountSid)}.json`
+}
+
+function basic(accountSid: string, authToken: string): Record<string, string> {
+  return { Authorization: `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString('base64')}` }
+}
+
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(credentials, ['accountSid', 'authToken'], 'Twilio')
+    if (missing) return missing
+    return verifyByGet(
+      accountUrl(credentials.accountSid),
+      basic(credentials.accountSid, credentials.authToken),
+      'Twilio'
+    )
+  },
+  {
+    identify: async ({ credentials }) => ({
+      id: credentials.accountSid,
+      name: credentials.accountSid,
+    }),
+  }
+)

+ 3 - 0
src/integrations/vonage-sms/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('vonage-sms')

+ 13 - 0
src/integrations/vonage-sms/server.ts

@@ -0,0 +1,13 @@
+import { messagingConnector, requireFields } from '../messaging/factory'
+import { manifest } from './manifest'
+
+/**
+ * Vonage wants the key and secret as query parameters on its account
+ * endpoints, so a live check would put the secret in a URL. The keys are
+ * proven by the first message instead, and a failure there surfaces on the
+ * connection with Vonage's own wording.
+ */
+export const connector = messagingConnector(manifest, async ({ credentials }) => {
+  const missing = requireFields(credentials, ['apiKey', 'apiSecret'], 'Vonage')
+  return missing ?? { ok: true }
+})

+ 3 - 0
src/integrations/whatsapp-meta/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('whatsapp-meta')

+ 28 - 0
src/integrations/whatsapp-meta/server.ts

@@ -0,0 +1,28 @@
+import { messagingConnector, requireFields, verifyByGet } from '../messaging/factory'
+import { manifest } from './manifest'
+
+const DEFAULT_GRAPH_VERSION = 'v21.0'
+
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(
+      credentials,
+      ['phoneNumberId', 'accessToken', 'verifyToken'],
+      'WhatsApp'
+    )
+    if (missing) return missing
+    const version = credentials.apiVersion?.trim() || DEFAULT_GRAPH_VERSION
+    return verifyByGet(
+      `https://graph.facebook.com/${version}/${encodeURIComponent(credentials.phoneNumberId)}`,
+      { Authorization: `Bearer ${credentials.accessToken}` },
+      'Meta'
+    )
+  },
+  {
+    identify: async ({ credentials }) => ({
+      id: credentials.phoneNumberId,
+      name: credentials.phoneNumberId,
+    }),
+  }
+)

+ 3 - 0
src/integrations/whatsapp-twilio/manifest.ts

@@ -0,0 +1,3 @@
+import { messagingManifestFor } from '../messaging/factory'
+
+export const manifest = messagingManifestFor('whatsapp-twilio')

+ 24 - 0
src/integrations/whatsapp-twilio/server.ts

@@ -0,0 +1,24 @@
+import { messagingConnector, requireFields, verifyByGet } from '../messaging/factory'
+import { manifest } from './manifest'
+
+export const connector = messagingConnector(
+  manifest,
+  async ({ credentials }) => {
+    const missing = requireFields(credentials, ['accountSid', 'authToken'], 'Twilio')
+    if (missing) return missing
+    const auth = Buffer.from(`${credentials.accountSid}:${credentials.authToken}`).toString(
+      'base64'
+    )
+    return verifyByGet(
+      `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(credentials.accountSid)}.json`,
+      { Authorization: `Basic ${auth}` },
+      'Twilio'
+    )
+  },
+  {
+    identify: async ({ credentials }) => ({
+      id: credentials.accountSid,
+      name: credentials.accountSid,
+    }),
+  }
+)

+ 64 - 30
src/lib/email.ts

@@ -5,6 +5,13 @@ import Mailgun from 'mailgun.js'
 import FormData from 'form-data'
 import sgMail, { type MailDataRequired } from '@sendgrid/mail'
 import { SESClient, SendRawEmailCommand } from '@aws-sdk/client-ses'
+import {
+  asLegacyMap,
+  channelProvider,
+  channelSettings,
+  legacyProviderNamed,
+} from '@/features/integrations/Lib/messaging'
+import { messagingProvider } from '@/integrations/messaging/catalog'
 import { db } from './db'
 import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema'
 import { ORG_EMAIL_KEYS } from '@/features/email/Schema/emailSettingsSchema'
@@ -33,13 +40,6 @@ async function getSystemSettings(keys: string[]): Promise<SettingsMap> {
   return new Map(rows.map((r) => [r.key, r.value]))
 }
 
-async function getOrgSettings(organizationId: string, keys: string[]): Promise<SettingsMap> {
-  const rows = await db.appSetting.findMany({
-    where: { organizationId, key: { in: keys } },
-  })
-  return new Map(rows.map((r) => [r.key, r.value]))
-}
-
 // ─── System-level helpers (read from SystemSetting + env) ───────────────────
 
 async function getEmailProvider(): Promise<EmailProvider> {
@@ -525,27 +525,25 @@ export async function sendMail(options: SendMailOptions) {
 
 // ─── Org-level email ────────────────────────────────────────────────────────
 
-async function getOrgEmailProvider(organizationId: string): Promise<EmailProvider | null> {
-  const setting = await db.appSetting.findUnique({
-    where: {
-      organizationId_key: {
-        organizationId,
-        key: ORG_EMAIL_KEYS.EMAIL_PROVIDER,
-      },
-    },
-  })
-  const value = setting?.value
-  if (
+function isEmailProvider(value: string | null | undefined): value is EmailProvider {
+  return (
     value === 'smtp' ||
     value === 'resend' ||
     value === 'postmark' ||
     value === 'mailgun' ||
     value === 'sendgrid' ||
     value === 'ses'
-  ) {
-    return value
-  }
-  return null
+  )
+}
+
+/**
+ * The workshop's own mail vendor, from the integration it connected. An
+ * organization that never set one up falls through to the platform's mail
+ * settings, which is what keeps email working everywhere by default.
+ */
+async function getOrgEmailProvider(organizationId: string): Promise<EmailProvider | null> {
+  const value = await channelProvider(organizationId, 'email')
+  return isEmailProvider(value) ? value : null
 }
 
 export async function getOrgFromAddress(organizationId: string): Promise<string> {
@@ -555,6 +553,39 @@ export async function getOrgFromAddress(organizationId: string): Promise<string>
     return getFromAddress()
   }
 
+  const settings = await channelSettings(organizationId, 'email')
+  return orgFromAddressFor(provider, settings)
+}
+
+/**
+ * Send through one specific email connection rather than whichever the
+ * organization is pointed at. The connection page's "send a test email" runs
+ * through here, so the vendor being looked at is the vendor being tested,
+ * even while a second one is still connected.
+ */
+export async function sendMailThroughConnection(
+  connectorId: string,
+  credentials: Record<string, string>,
+  settings: Record<string, unknown>,
+  options: Omit<SendMailOptions, 'from'>
+): Promise<{ from: string }> {
+  const provider = messagingProvider(connectorId)
+  if (!provider || !isEmailProvider(provider.legacyProvider)) {
+    throw new Error(`${connectorId} is not an email integration`)
+  }
+  const map = asLegacyMap({
+    connectionId: '',
+    connectorId,
+    provider,
+    credentials,
+    settings,
+  })
+  const from = orgFromAddressFor(provider.legacyProvider, map)
+  await sendWithProvider(provider.legacyProvider, { ...options, from }, map, 'org')
+  return { from }
+}
+
+function orgFromAddressFor(provider: EmailProvider, settings: SettingsMap): string {
   const keyMap: Record<EmailProvider, { email: string; name: string }> = {
     smtp: {
       email: ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_EMAIL,
@@ -583,7 +614,6 @@ export async function getOrgFromAddress(organizationId: string): Promise<string>
   }
 
   const keys = keyMap[provider]
-  const settings = await getOrgSettings(organizationId, [keys.email, keys.name])
 
   const fromEmail = settings.get(keys.email) || 'noreply@example.com'
   const fromName = settings.get(keys.name) || 'Torqvoice'
@@ -592,17 +622,21 @@ export async function getOrgFromAddress(organizationId: string): Promise<string>
 }
 
 export async function sendOrgMail(organizationId: string, options: SendMailOptions) {
-  const provider = await getOrgEmailProvider(organizationId)
-
-  if (!provider) {
+  const settings = await channelSettings(organizationId, 'email')
+  const provider = settings.get(ORG_EMAIL_KEYS.EMAIL_PROVIDER)
+
+  if (!isEmailProvider(provider)) {
+    // A workshop that named a vendor but never finished its setup used to
+    // get that vendor's error. It still does, rather than a quiet send from
+    // the platform's account that its customers would not recognise.
+    const named = await legacyProviderNamed(organizationId, 'email')
+    if (named) {
+      throw new Error(`Email provider ${named} is not fully configured. Check its integration.`)
+    }
     // Fall back to global platform email
     await sendMail(options)
     return
   }
 
-  // Load all org email settings
-  const allKeys = Object.values(ORG_EMAIL_KEYS)
-  const settings = await getOrgSettings(organizationId, allKeys)
-
   await sendWithProvider(provider, options, settings, 'org')
 }

+ 32 - 34
src/lib/sms.ts

@@ -1,4 +1,5 @@
 import { db } from './db'
+import { channelProvider, channelSettings } from '@/features/integrations/Lib/messaging'
 import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { normalizePortalPhone } from './portal-phone'
@@ -12,23 +13,17 @@ export interface SendSmsOptions {
 
 type SettingsMap = Map<string, string>
 
-async function getOrgSettings(organizationId: string, keys: string[]): Promise<SettingsMap> {
-  const rows = await db.appSetting.findMany({
-    where: { organizationId, key: { in: keys } },
-  })
-  return new Map(rows.map((r) => [r.key, r.value]))
+/**
+ * The workshop's SMS vendor and its keys, from the integration it connected.
+ * A setup made before SMS moved into the catalog is adopted on the way past,
+ * so this keeps answering for workshops that never touched the new screen.
+ */
+async function smsSettings(organizationId: string): Promise<SettingsMap> {
+  return channelSettings(organizationId, 'sms')
 }
 
 export async function getOrgSmsProvider(organizationId: string): Promise<SmsProvider | null> {
-  const setting = await db.appSetting.findUnique({
-    where: {
-      organizationId_key: {
-        organizationId,
-        key: ORG_SMS_KEYS.SMS_PROVIDER,
-      },
-    },
-  })
-  const value = setting?.value
+  const value = await channelProvider(organizationId, 'sms')
   if (value === 'twilio' || value === 'vonage' || value === 'telnyx') {
     return value
   }
@@ -36,15 +31,8 @@ export async function getOrgSmsProvider(organizationId: string): Promise<SmsProv
 }
 
 export async function getOrgSmsPhoneNumber(organizationId: string): Promise<string | null> {
-  const setting = await db.appSetting.findUnique({
-    where: {
-      organizationId_key: {
-        organizationId,
-        key: ORG_SMS_KEYS.SMS_PHONE_NUMBER,
-      },
-    },
-  })
-  return setting?.value || null
+  const settings = await smsSettings(organizationId)
+  return settings.get(ORG_SMS_KEYS.SMS_PHONE_NUMBER) || null
 }
 
 // ─── Twilio ──────────────────────────────────────────────────────────────────
@@ -228,24 +216,34 @@ export async function sendOrgSms(
   organizationId: string,
   options: SendSmsOptions
 ): Promise<SendSmsResult> {
-  const provider = await getOrgSmsProvider(organizationId)
-  if (!provider) {
+  // One read of the connection covers the vendor, its keys and the number,
+  // and the country code is a workshop setting rather than the vendor's.
+  const [settings, countryRow] = await Promise.all([
+    smsSettings(organizationId),
+    db.appSetting.findUnique({
+      where: {
+        organizationId_key: {
+          organizationId,
+          key: SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE,
+        },
+      },
+      select: { value: true },
+    }),
+  ])
+
+  const provider = settings.get(ORG_SMS_KEYS.SMS_PROVIDER)
+  if (provider !== 'twilio' && provider !== 'vonage' && provider !== 'telnyx') {
     throw new Error('SMS is not configured. Set up an SMS provider in Settings.')
   }
 
-  const from = await getOrgSmsPhoneNumber(organizationId)
+  const from = settings.get(ORG_SMS_KEYS.SMS_PHONE_NUMBER)
   if (!from) {
     throw new Error('SMS phone number is not configured.')
   }
 
-  // Load the SMS provider settings + the workshop's default country code
-  // in a single query so we can normalize the destination phone before
-  // any provider sees it. Twilio/Vonage/Telnyx all require strict E.164.
-  const allKeys = [...Object.values(ORG_SMS_KEYS), SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE]
-  const settings = await getOrgSettings(organizationId, allKeys)
-
-  const defaultCountryCode = settings.get(SETTING_KEYS.WORKSHOP_DEFAULT_COUNTRY_CODE) ?? null
-  const normalizedTo = normalizePortalPhone(options.to, defaultCountryCode)
+  // Twilio, Vonage and Telnyx all require strict E.164, so the destination is
+  // normalized before any of them sees it.
+  const normalizedTo = normalizePortalPhone(options.to, countryRow?.value ?? null)
   if (!normalizedTo) {
     throw new Error(
       'Invalid phone number format. Must be E.164 format (e.g. +15551234567), ' +

+ 23 - 10
src/lib/telegram.ts

@@ -1,23 +1,36 @@
-import { db } from './db'
+import { channelSettings } from '@/features/integrations/Lib/messaging'
 import { ORG_TELEGRAM_KEYS } from '@/features/telegram/Schema/telegramSettingsSchema'
 
 const TELEGRAM_API_BASE = 'https://api.telegram.org/bot'
 
 // ─── Settings helpers ───────────────────────────────────────────────────────
 
-async function getOrgSetting(organizationId: string, key: string): Promise<string | null> {
-  const setting = await db.appSetting.findUnique({
-    where: { organizationId_key: { organizationId, key } },
-  })
-  return setting?.value || null
-}
-
+/**
+ * Telegram's bot token and name come from the connected integration now. A
+ * bot set up before the move is adopted on the first read, so nothing has to
+ * be pasted again.
+ */
 export async function getOrgTelegramBotToken(organizationId: string): Promise<string | null> {
-  return getOrgSetting(organizationId, ORG_TELEGRAM_KEYS.TELEGRAM_BOT_TOKEN)
+  const settings = await channelSettings(organizationId, 'telegram')
+  return settings.get(ORG_TELEGRAM_KEYS.TELEGRAM_BOT_TOKEN) || null
 }
 
 export async function getOrgTelegramBotUsername(organizationId: string): Promise<string | null> {
-  return getOrgSetting(organizationId, ORG_TELEGRAM_KEYS.TELEGRAM_BOT_USERNAME)
+  // Printed on invoices and the customer portal: a broken Telegram setup
+  // must cost the workshop the t.me link, never the invoice.
+  try {
+    const settings = await channelSettings(organizationId, 'telegram')
+    return settings.get(ORG_TELEGRAM_KEYS.TELEGRAM_BOT_USERNAME) || null
+  } catch (error) {
+    console.error('[telegram] Could not read the bot username:', error)
+    return null
+  }
+}
+
+/** The secret Telegram signs its webhook calls with. */
+export async function getOrgTelegramWebhookSecret(organizationId: string): Promise<string | null> {
+  const settings = await channelSettings(organizationId, 'telegram')
+  return settings.get(ORG_TELEGRAM_KEYS.TELEGRAM_WEBHOOK_SECRET) || null
 }
 
 // ─── Bot API: getMe ─────────────────────────────────────────────────────────

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio