ソースを参照

add option to send mail when new service request (#204)

Bernt Christian Egeland 1 ヶ月 前
親
コミット
6c72d0531e

+ 15 - 0
messages/de/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "Portal-URL",
     "urlCopied": "Portal-URL kopiert",
     "shareHint": "Teilen Sie diesen Link mit Ihren Kunden, damit sie sich in ihr Portal einloggen konnen.",
+    "serviceRequestAlertsHint": "Serviceanfragen aus dem Portal erscheinen immer in Ihrer Benachrichtigungsglocke. Sie können sie zusätzlich per E-Mail erhalten.",
+    "serviceRequestAlertsLink": "Benachrichtigungseinstellungen →",
     "descriptionLabel": "Über Ihre Werkstatt",
     "descriptionPlaceholder": "Erzählen Sie Ihren Kunden etwas über Ihre Werkstatt, die angebotenen Leistungen und was Sie auszeichnet.",
     "descriptionHint": "Wird Besuchern oben auf Ihrer Portal-Startseite angezeigt.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Warnungen",
     "description": "Wählen Sie, über welche Ereignisse Sie wie informiert werden.",
+    "serviceRequest": {
+      "title": "Benachrichtigungen zu Serviceanfragen",
+      "description": "Serviceanfragen aus dem Kundenportal erscheinen immer in der Benachrichtigungsglocke. Aktivieren Sie diese Option, um sie zusätzlich per E-Mail zu erhalten.",
+      "email": "E-Mail-Benachrichtigung",
+      "emailHint": "Standardmäßig aus. Eine E-Mail pro Anfrage, versendet sobald ein Kunde sie absendet.",
+      "recipients": "Senden an",
+      "recipientsHint": "Leer lassen, um alle Inhaber und Administratoren zu benachrichtigen. Mehrere Adressen mit Komma trennen.",
+      "invalidRecipients": "Keine gültige Adresse: {list}",
+      "emailSetupHint": "Benachrichtigungen werden über Ihren eingerichteten E-Mail-Anbieter versendet.",
+      "emailSetupLink": "E-Mail-Einstellungen →",
+      "save": "Benachrichtigungseinstellungen speichern",
+      "saved": "Einstellungen für Serviceanfragen gespeichert"
+    },
     "lowStock": {
       "title": "Bestandswarnungen",
       "description": "Warnt, wenn ein Teil seinen Meldebestand erreicht. Ein Teil gilt als knapp, wenn der Bestand auf oder unter die \"Mindestmenge\" fällt — oder unter den Standardwert unten, falls keine gesetzt ist. Jedes Teil wird pro Unterschreitung einmal gemeldet und bleibt bis zum Auffüllen still.",

+ 15 - 0
messages/en/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "Portal URL",
     "urlCopied": "Portal URL copied",
     "shareHint": "Share this link with your customers so they can log in to their portal.",
+    "serviceRequestAlertsHint": "Service requests from the portal always appear in your notification bell. You can also get them by email.",
+    "serviceRequestAlertsLink": "Alert settings →",
     "descriptionLabel": "About your workshop",
     "descriptionPlaceholder": "Tell customers a bit about your workshop, the services you offer, and what makes you unique.",
     "descriptionHint": "Shown to visitors at the top of your portal landing page.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Alerts",
     "description": "Choose which events you want to be told about, and how.",
+    "serviceRequest": {
+      "title": "Service request alerts",
+      "description": "Service requests submitted through the customer portal always show up in the notification bell. Turn this on to also get them by email.",
+      "email": "Email notification",
+      "emailHint": "Off by default. One email per request, sent as soon as a customer submits it.",
+      "recipients": "Send to",
+      "recipientsHint": "Leave empty to notify every owner and admin. Separate several addresses with commas.",
+      "invalidRecipients": "Not a valid address: {list}",
+      "emailSetupHint": "Alerts are sent with your configured mail provider.",
+      "emailSetupLink": "Email settings →",
+      "save": "Save alert settings",
+      "saved": "Service request alert settings saved"
+    },
     "lowStock": {
       "title": "Low stock alerts",
       "description": "Alerts when a part reaches its reorder point. A part is low when its quantity drops to or below its \"Min Qty\", or the default below if it has none. Each part is reported once per dip and stays quiet until it is restocked.",

+ 15 - 0
messages/es/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "URL del portal",
     "urlCopied": "URL del portal copiada",
     "shareHint": "Comparta este enlace con sus clientes para que puedan iniciar sesion en su portal.",
+    "serviceRequestAlertsHint": "Las solicitudes de servicio del portal siempre aparecen en tu campana de notificaciones. También puedes recibirlas por correo.",
+    "serviceRequestAlertsLink": "Ajustes de avisos →",
     "descriptionLabel": "Sobre tu taller",
     "descriptionPlaceholder": "Cuenta a tus clientes algo sobre tu taller, los servicios que ofreces y lo que te hace único.",
     "descriptionHint": "Se muestra a los visitantes en la parte superior de la página de tu portal.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Alertas",
     "description": "Elija sobre qué eventos quiere recibir avisos y cómo.",
+    "serviceRequest": {
+      "title": "Avisos de solicitudes de servicio",
+      "description": "Las solicitudes de servicio enviadas desde el portal del cliente siempre aparecen en la campana de notificaciones. Activa esta opción para recibirlas también por correo.",
+      "email": "Notificación por correo",
+      "emailHint": "Desactivado por defecto. Un correo por solicitud, enviado en cuanto el cliente la envía.",
+      "recipients": "Enviar a",
+      "recipientsHint": "Déjalo vacío para avisar a todos los propietarios y administradores. Separa varias direcciones con comas.",
+      "invalidRecipients": "Dirección no válida: {list}",
+      "emailSetupHint": "Los avisos se envían con el proveedor de correo que tengas configurado.",
+      "emailSetupLink": "Ajustes de correo →",
+      "save": "Guardar ajustes de avisos",
+      "saved": "Ajustes de avisos de solicitudes guardados"
+    },
     "lowStock": {
       "title": "Alertas de stock bajo",
       "description": "Avisa cuando una pieza alcanza su punto de pedido. Una pieza está baja cuando su cantidad cae a o por debajo de su \"Cant. mínima\", o del valor predeterminado si no tiene. Cada pieza se informa una vez por caída y no vuelve a avisar hasta reponerla.",

+ 15 - 0
messages/fr/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "URL du portail",
     "urlCopied": "URL du portail copiee",
     "shareHint": "Partagez ce lien avec vos clients pour qu'ils puissent se connecter a leur portail.",
+    "serviceRequestAlertsHint": "Les demandes de service du portail apparaissent toujours dans votre cloche de notifications. Vous pouvez aussi les recevoir par e-mail.",
+    "serviceRequestAlertsLink": "Paramètres d'alerte →",
     "descriptionLabel": "À propos de votre garage",
     "descriptionPlaceholder": "Présentez votre garage à vos clients : vos services, votre équipe, ce qui vous rend unique.",
     "descriptionHint": "Affiché aux visiteurs en haut de la page d'accueil de votre portail.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Alertes",
     "description": "Choisissez les événements dont vous voulez être averti, et comment.",
+    "serviceRequest": {
+      "title": "Alertes de demandes de service",
+      "description": "Les demandes de service envoyées via le portail client apparaissent toujours dans la cloche de notifications. Activez cette option pour les recevoir aussi par e-mail.",
+      "email": "Notification par e-mail",
+      "emailHint": "Désactivé par défaut. Un e-mail par demande, envoyé dès qu'un client la soumet.",
+      "recipients": "Envoyer à",
+      "recipientsHint": "Laissez vide pour avertir tous les propriétaires et administrateurs. Séparez plusieurs adresses par des virgules.",
+      "invalidRecipients": "Adresse non valide : {list}",
+      "emailSetupHint": "Les alertes sont envoyées via le fournisseur de messagerie que vous avez configuré.",
+      "emailSetupLink": "Paramètres e-mail →",
+      "save": "Enregistrer les paramètres d'alerte",
+      "saved": "Paramètres d'alerte des demandes enregistrés"
+    },
     "lowStock": {
       "title": "Alertes de stock bas",
       "description": "Alerte quand une pièce atteint son seuil de réapprovisionnement. Une pièce est basse lorsque sa quantité descend à ou sous sa \"Qté min\", ou sous la valeur par défaut ci-dessous si elle n'en a pas. Chaque pièce est signalée une fois par baisse et reste silencieuse jusqu'au réassort.",

+ 15 - 0
messages/it/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "URL del portale",
     "urlCopied": "URL del portale copiato",
     "shareHint": "Condividi questo link con i tuoi clienti per permettere loro di accedere al portale.",
+    "serviceRequestAlertsHint": "Le richieste di servizio dal portale compaiono sempre nella campanella delle notifiche. Puoi riceverle anche via e-mail.",
+    "serviceRequestAlertsLink": "Impostazioni avvisi →",
     "descriptionLabel": "La tua officina",
     "descriptionPlaceholder": "Racconta ai clienti qualcosa sulla tua officina, i servizi che offri e ciò che ti rende unico.",
     "descriptionHint": "Mostrato ai visitatori in cima alla pagina del portale.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Avvisi",
     "description": "Scegli di quali eventi vuoi essere avvisato e come.",
+    "serviceRequest": {
+      "title": "Avvisi per richieste di servizio",
+      "description": "Le richieste di servizio inviate dal portale clienti compaiono sempre nella campanella delle notifiche. Attiva questa opzione per riceverle anche via e-mail.",
+      "email": "Notifica e-mail",
+      "emailHint": "Disattivato per impostazione predefinita. Una e-mail per richiesta, inviata appena il cliente la invia.",
+      "recipients": "Invia a",
+      "recipientsHint": "Lascia vuoto per avvisare tutti i proprietari e gli amministratori. Separa più indirizzi con virgole.",
+      "invalidRecipients": "Indirizzo non valido: {list}",
+      "emailSetupHint": "Gli avvisi vengono inviati con il provider e-mail che hai configurato.",
+      "emailSetupLink": "Impostazioni e-mail →",
+      "save": "Salva impostazioni avvisi",
+      "saved": "Impostazioni avvisi richieste salvate"
+    },
     "lowStock": {
       "title": "Avvisi scorte basse",
       "description": "Avvisa quando un ricambio raggiunge il punto di riordino. Un ricambio è basso quando la quantità scende a o sotto la sua \"Qtà min\", o il valore predefinito se non ne ha. Ogni ricambio viene segnalato una volta per calo e resta silenzioso fino al riassortimento.",

+ 15 - 0
messages/lt/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "Portalo URL",
     "urlCopied": "Portalo URL nukopijuotas",
     "shareHint": "Pasidalinkite šia nuoroda su klientais, kad jie galėtų prisijungti prie savo portalo.",
+    "serviceRequestAlertsHint": "Serviso užklausos iš portalo visada rodomos pranešimų varpelyje. Jas taip pat galite gauti el. paštu.",
+    "serviceRequestAlertsLink": "Pranešimų nustatymai →",
     "descriptionLabel": "Apie jūsų dirbtuves",
     "descriptionPlaceholder": "Papasakokite klientams apie savo dirbtuves, siūlomas paslaugas ir kuo esate unikalūs.",
     "descriptionHint": "Rodoma lankytojams jūsų portalo pradžios puslapio viršuje.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Įspėjimai",
     "description": "Pasirinkite, apie kuriuos įvykius ir kaip norite gauti pranešimus.",
+    "serviceRequest": {
+      "title": "Pranešimai apie serviso užklausas",
+      "description": "Serviso užklausos, pateiktos per klientų portalą, visada rodomos pranešimų varpelyje. Įjunkite, kad jas gautumėte ir el. paštu.",
+      "email": "Pranešimas el. paštu",
+      "emailHint": "Pagal nutylėjimą išjungta. Vienas laiškas kiekvienai užklausai, siunčiamas iškart ją pateikus.",
+      "recipients": "Siųsti adresu",
+      "recipientsHint": "Palikite tuščią, kad būtų informuoti visi savininkai ir administratoriai. Kelis adresus atskirkite kableliais.",
+      "invalidRecipients": "Netinkamas adresas: {list}",
+      "emailSetupHint": "Pranešimai siunčiami per jūsų sukonfigūruotą pašto tiekėją.",
+      "emailSetupLink": "El. pašto nustatymai →",
+      "save": "Įrašyti pranešimų nustatymus",
+      "saved": "Serviso užklausų pranešimų nustatymai įrašyti"
+    },
     "lowStock": {
       "title": "Mažų atsargų įspėjimai",
       "description": "Įspėja, kai dalis pasiekia užsakymo ribą. Dalis laikoma mažėjančia, kai kiekis nukrenta iki arba žemiau \"Min. kiekis\", arba žemiau numatytosios ribos, jei jos nėra. Kiekviena dalis pranešama vieną kartą per kritimą ir tyli, kol bus papildyta.",

+ 15 - 0
messages/nb/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "Portal-URL",
     "urlCopied": "Portal-URL kopiert",
     "shareHint": "Del denne lenken med kundene dine slik at de kan logge inn pa portalen sin.",
+    "serviceRequestAlertsHint": "Serviceforespørsler fra portalen dukker alltid opp i varselbjella. Du kan også få dem på e-post.",
+    "serviceRequestAlertsLink": "Varselinnstillinger →",
     "descriptionLabel": "Om verkstedet",
     "descriptionPlaceholder": "Fortell kundene litt om verkstedet, tjenestene dere tilbyr og hva som gjor dere unike.",
     "descriptionHint": "Vises til besokende overst pa portalsiden.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Varsler",
     "description": "Velg hvilke hendelser du vil varsles om, og hvordan.",
+    "serviceRequest": {
+      "title": "Varsler om serviceforespørsler",
+      "description": "Serviceforespørsler som sendes inn via kundeportalen dukker alltid opp i varselbjella. Slå på dette for også å få dem på e-post.",
+      "email": "E-postvarsel",
+      "emailHint": "Av som standard. Én e-post per forespørsel, sendt så snart en kunde sender den inn.",
+      "recipients": "Send til",
+      "recipientsHint": "La stå tomt for å varsle alle eiere og administratorer. Skill flere adresser med komma.",
+      "invalidRecipients": "Ikke en gyldig adresse: {list}",
+      "emailSetupHint": "Varsler sendes med e-postleverandøren du har satt opp.",
+      "emailSetupLink": "E-postinnstillinger →",
+      "save": "Lagre varselinnstillinger",
+      "saved": "Innstillinger for serviceforespørsler lagret"
+    },
     "lowStock": {
       "title": "Varsler om lavt lager",
       "description": "Varsler når en del når bestillingspunktet. En del er lav når antallet faller til eller under \"Min. antall\", eller standardverdien under hvis den mangler. Hver del rapporteres én gang per fall og er stille til den er fylt opp igjen.",

+ 15 - 0
messages/nl/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "Portaal-URL",
     "urlCopied": "Portaal-URL gekopieerd",
     "shareHint": "Deel deze link met uw klanten zodat ze kunnen inloggen op hun portaal.",
+    "serviceRequestAlertsHint": "Serviceaanvragen uit het portaal verschijnen altijd in je meldingsbel. Je kunt ze ook per e-mail ontvangen.",
+    "serviceRequestAlertsLink": "Meldingsinstellingen →",
     "descriptionLabel": "Over uw werkplaats",
     "descriptionPlaceholder": "Vertel klanten iets over uw werkplaats, de diensten die u aanbiedt en wat u uniek maakt.",
     "descriptionHint": "Wordt aan bezoekers getoond bovenaan uw portaalpagina.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Meldingen",
     "description": "Kies over welke gebeurtenissen u bericht wilt, en hoe.",
+    "serviceRequest": {
+      "title": "Meldingen voor serviceaanvragen",
+      "description": "Serviceaanvragen via het klantenportaal verschijnen altijd in de meldingsbel. Zet dit aan om ze ook per e-mail te ontvangen.",
+      "email": "E-mailmelding",
+      "emailHint": "Standaard uit. Eén e-mail per aanvraag, verstuurd zodra een klant deze indient.",
+      "recipients": "Sturen naar",
+      "recipientsHint": "Laat leeg om alle eigenaren en beheerders te informeren. Scheid meerdere adressen met komma's.",
+      "invalidRecipients": "Geen geldig adres: {list}",
+      "emailSetupHint": "Meldingen worden verstuurd via de e-mailprovider die je hebt ingesteld.",
+      "emailSetupLink": "E-mailinstellingen →",
+      "save": "Meldingsinstellingen opslaan",
+      "saved": "Instellingen voor serviceaanvragen opgeslagen"
+    },
     "lowStock": {
       "title": "Meldingen lage voorraad",
       "description": "Waarschuwt wanneer een onderdeel zijn bestelpunt bereikt. Een onderdeel is laag als de voorraad daalt tot of onder het \"Min. aantal\", of de standaardwaarde hieronder als het er geen heeft. Elk onderdeel wordt één keer per daling gemeld en blijft stil tot het is aangevuld.",

+ 15 - 0
messages/pl/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "URL portalu",
     "urlCopied": "URL portalu skopiowany",
     "shareHint": "Udostepnij ten link swoim klientom, aby mogli zalogowac sie do swojego portalu.",
+    "serviceRequestAlertsHint": "Zgłoszenia serwisowe z portalu zawsze pojawiają się w dzwonku powiadomień. Możesz je otrzymywać także e-mailem.",
+    "serviceRequestAlertsLink": "Ustawienia powiadomień →",
     "descriptionLabel": "O Twoim warsztacie",
     "descriptionPlaceholder": "Opowiedz klientom o swoim warsztacie, oferowanych usługach i tym, co Cię wyróżnia.",
     "descriptionHint": "Wyświetlane odwiedzającym na górze strony portalu.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Alerty",
     "description": "Wybierz, o których zdarzeniach chcesz być informowany i w jaki sposób.",
+    "serviceRequest": {
+      "title": "Powiadomienia o zgłoszeniach serwisowych",
+      "description": "Zgłoszenia serwisowe wysłane przez portal klienta zawsze pojawiają się w dzwonku powiadomień. Włącz tę opcję, aby otrzymywać je także e-mailem.",
+      "email": "Powiadomienie e-mail",
+      "emailHint": "Domyślnie wyłączone. Jeden e-mail na zgłoszenie, wysyłany zaraz po jego złożeniu przez klienta.",
+      "recipients": "Wyślij do",
+      "recipientsHint": "Pozostaw puste, aby powiadomić wszystkich właścicieli i administratorów. Kilka adresów oddziel przecinkami.",
+      "invalidRecipients": "Nieprawidłowy adres: {list}",
+      "emailSetupHint": "Powiadomienia są wysyłane przez skonfigurowanego dostawcę poczty.",
+      "emailSetupLink": "Ustawienia e-mail →",
+      "save": "Zapisz ustawienia powiadomień",
+      "saved": "Zapisano ustawienia powiadomień o zgłoszeniach"
+    },
     "lowStock": {
       "title": "Alerty niskiego stanu",
       "description": "Ostrzega, gdy część osiąga próg zamawiania. Część jest niska, gdy jej ilość spadnie do lub poniżej \"Min. ilość\", albo wartości domyślnej poniżej, jeśli jej nie ustawiono. Każda część jest zgłaszana raz na spadek i milczy do czasu uzupełnienia.",

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

@@ -969,6 +969,8 @@
     "portalUrl": "URL do portal",
     "urlCopied": "URL do portal copiada",
     "shareHint": "Compartilhe este link com seus clientes para que possam fazer login no portal.",
+    "serviceRequestAlertsHint": "As solicitações de serviço do portal sempre aparecem no seu sino de notificações. Você também pode recebê-las por e-mail.",
+    "serviceRequestAlertsLink": "Configurações de alerta →",
     "descriptionLabel": "Sobre sua oficina",
     "descriptionPlaceholder": "Conte aos clientes sobre sua oficina, os serviços que você oferece e o que torna seu trabalho único.",
     "descriptionHint": "Mostrado aos visitantes no topo da página inicial do seu portal.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Alertas",
     "description": "Escolha sobre quais eventos deseja ser avisado e como.",
+    "serviceRequest": {
+      "title": "Alertas de solicitações de serviço",
+      "description": "Solicitações de serviço enviadas pelo portal do cliente sempre aparecem no sino de notificações. Ative esta opção para recebê-las também por e-mail.",
+      "email": "Notificação por e-mail",
+      "emailHint": "Desativado por padrão. Um e-mail por solicitação, enviado assim que o cliente a envia.",
+      "recipients": "Enviar para",
+      "recipientsHint": "Deixe vazio para avisar todos os proprietários e administradores. Separe vários endereços com vírgulas.",
+      "invalidRecipients": "Endereço inválido: {list}",
+      "emailSetupHint": "Os alertas são enviados pelo provedor de e-mail que você configurou.",
+      "emailSetupLink": "Configurações de e-mail →",
+      "save": "Salvar configurações de alerta",
+      "saved": "Configurações de alerta de solicitações salvas"
+    },
     "lowStock": {
       "title": "Alertas de estoque baixo",
       "description": "Avisa quando uma peça atinge seu ponto de reposição. Uma peça está baixa quando a quantidade cai para ou abaixo da \"Qtd. mín.\", ou do padrão abaixo se não tiver. Cada peça é informada uma vez por queda e fica em silêncio até ser reposta.",

+ 15 - 0
messages/ru/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "URL портала",
     "urlCopied": "URL портала скопирован",
     "shareHint": "Поделитесь этой ссылкой с клиентами, чтобы они могли войти в свой портал.",
+    "serviceRequestAlertsHint": "Заявки на обслуживание из портала всегда появляются в колокольчике уведомлений. Вы также можете получать их по электронной почте.",
+    "serviceRequestAlertsLink": "Настройки уведомлений →",
     "descriptionLabel": "О вашей мастерской",
     "descriptionPlaceholder": "Расскажите клиентам о своей мастерской, услугах и о том, что делает вас уникальными.",
     "descriptionHint": "Отображается посетителям в верхней части главной страницы портала.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Оповещения",
     "description": "Выберите, о каких событиях и каким способом вас уведомлять.",
+    "serviceRequest": {
+      "title": "Уведомления о заявках на обслуживание",
+      "description": "Заявки, отправленные через клиентский портал, всегда появляются в колокольчике уведомлений. Включите, чтобы получать их также по электронной почте.",
+      "email": "Уведомление по электронной почте",
+      "emailHint": "По умолчанию выключено. Одно письмо на заявку, отправляется сразу после ее отправки клиентом.",
+      "recipients": "Отправлять на",
+      "recipientsHint": "Оставьте пустым, чтобы уведомлять всех владельцев и администраторов. Несколько адресов разделяйте запятыми.",
+      "invalidRecipients": "Неверный адрес: {list}",
+      "emailSetupHint": "Уведомления отправляются через настроенного почтового провайдера.",
+      "emailSetupLink": "Настройки почты →",
+      "save": "Сохранить настройки уведомлений",
+      "saved": "Настройки уведомлений о заявках сохранены"
+    },
     "lowStock": {
       "title": "Оповещения о низком запасе",
       "description": "Предупреждает, когда деталь достигает точки заказа. Деталь считается заканчивающейся, когда количество падает до или ниже \"Мин. кол-во\", либо значения по умолчанию ниже, если оно не задано. Каждая деталь сообщается один раз за спад и молчит до пополнения.",

+ 15 - 0
messages/tr/settings.json

@@ -969,6 +969,8 @@
     "portalUrl": "Portal URL'si",
     "urlCopied": "Portal URL'si kopyalandı",
     "shareHint": "Bu bağlantıyı müşterilerinizle paylaşarak portallarına giriş yapmalarını sağlayın.",
+    "serviceRequestAlertsHint": "Portaldan gelen servis talepleri her zaman bildirim zilinizde görünür. Bunları e-posta ile de alabilirsiniz.",
+    "serviceRequestAlertsLink": "Bildirim ayarları →",
     "descriptionLabel": "Servisiniz hakkında",
     "descriptionPlaceholder": "Müşterilerinize servisinizi, sunduğunuz hizmetleri ve sizi özel kılan şeyi anlatın.",
     "descriptionHint": "Ziyaretçilere portal ana sayfanızın üst kısmında gösterilir.",
@@ -1351,6 +1353,19 @@
   "alerts": {
     "title": "Uyarılar",
     "description": "Hangi olaylardan nasıl haberdar olmak istediğinizi seçin.",
+    "serviceRequest": {
+      "title": "Servis talebi bildirimleri",
+      "description": "Müşteri portalı üzerinden gönderilen servis talepleri her zaman bildirim zilinde görünür. E-posta ile de almak için bunu açın.",
+      "email": "E-posta bildirimi",
+      "emailHint": "Varsayılan olarak kapalı. Her talep için bir e-posta, müşteri gönderir göndermez iletilir.",
+      "recipients": "Gönderilecek adres",
+      "recipientsHint": "Tüm sahipleri ve yöneticileri bilgilendirmek için boş bırakın. Birden fazla adresi virgülle ayırın.",
+      "invalidRecipients": "Geçerli bir adres değil: {list}",
+      "emailSetupHint": "Bildirimler yapılandırdığınız e-posta sağlayıcısı ile gönderilir.",
+      "emailSetupLink": "E-posta ayarları →",
+      "save": "Bildirim ayarlarını kaydet",
+      "saved": "Servis talebi bildirim ayarları kaydedildi"
+    },
     "lowStock": {
       "title": "Düşük stok uyarıları",
       "description": "Bir parça sipariş noktasına ulaştığında uyarır. Bir parça, miktarı \"Min. miktar\" değerine veya altına düştüğünde düşüktür; tanımlı değilse aşağıdaki varsayılan kullanılır. Her parça düşüş başına bir kez bildirilir ve stok yenilenene kadar sessiz kalır.",

+ 147 - 0
src/__tests__/features/portal/service-request-alert.test.ts

@@ -0,0 +1,147 @@
+/**
+ * Tests for who the service request alert reaches, and for what the workshop
+ * ends up reading.
+ *
+ * The description is written by a customer in a public portal form and lands in
+ * an HTML email, so escaping is a correctness requirement rather than a nicety.
+ * The recipient rules matter for a different reason: a malformed address handed
+ * to a mail provider can sink the whole message, taking the valid recipients
+ * with it.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+  MAX_DESCRIPTION_IN_EMAIL,
+  buildServiceRequestEmailHtml,
+  buildServiceRequestSubject,
+  formatPreferredDate,
+  parseInvalidRecipients,
+  parseRecipientList,
+} from "@/features/portal/Lib/serviceRequestAlert";
+
+const emailInput = (overrides = {}) => ({
+  organizationName: "Ola's Garage",
+  customerName: "Kari Nordmann",
+  customerEmail: "kari@example.com",
+  customerPhone: "+47 900 00 000",
+  vehicleLabel: "Volvo V70 (AB12345)",
+  description: "Brakes squeal when cold.",
+  preferredDate: "2026-08-10",
+  requestUrl: "https://app.example.com/customers/c1?tab=requests",
+  ...overrides,
+});
+
+describe("parseRecipientList", () => {
+  it("returns nothing for an empty or unset field", () => {
+    expect(parseRecipientList("")).toEqual([]);
+    expect(parseRecipientList(null)).toEqual([]);
+    expect(parseRecipientList(undefined)).toEqual([]);
+    expect(parseRecipientList("   ")).toEqual([]);
+  });
+
+  it("accepts the separators an operator actually types", () => {
+    expect(parseRecipientList("a@example.com, b@example.com;c@example.com\nd@example.com")).toEqual([
+      "a@example.com",
+      "b@example.com",
+      "c@example.com",
+      "d@example.com",
+    ]);
+  });
+
+  it("drops entries that are not shaped like an address", () => {
+    expect(parseRecipientList("good@example.com, not-an-email, @example.com, x@y")).toEqual([
+      "good@example.com",
+    ]);
+  });
+
+  it("dedupes case-insensitively so nobody is mailed twice", () => {
+    expect(parseRecipientList("Owner@Example.com, owner@example.com")).toEqual([
+      "Owner@Example.com",
+    ]);
+  });
+});
+
+describe("parseInvalidRecipients", () => {
+  it("reports exactly what parseRecipientList would drop", () => {
+    const raw = "good@example.com, oops, another@example.com, @bad";
+    expect(parseInvalidRecipients(raw)).toEqual(["oops", "@bad"]);
+    expect(parseRecipientList(raw)).toEqual(["good@example.com", "another@example.com"]);
+  });
+
+  it("stays quiet when every entry is valid", () => {
+    expect(parseInvalidRecipients("a@example.com b@example.com")).toEqual([]);
+    expect(parseInvalidRecipients("")).toEqual([]);
+  });
+});
+
+describe("formatPreferredDate", () => {
+  it("renders an unambiguous ISO day", () => {
+    expect(formatPreferredDate(new Date("2026-08-10T00:00:00Z"))).toBe("2026-08-10");
+  });
+
+  it("returns null when there is no usable date", () => {
+    expect(formatPreferredDate(null)).toBeNull();
+    expect(formatPreferredDate(undefined)).toBeNull();
+    expect(formatPreferredDate(new Date("nonsense"))).toBeNull();
+  });
+});
+
+describe("buildServiceRequestSubject", () => {
+  it("names the customer and the vehicle so the inbox is scannable", () => {
+    expect(
+      buildServiceRequestSubject({ customerName: "Kari", vehicleLabel: "Volvo V70" }),
+    ).toBe("New service request from Kari (Volvo V70)");
+  });
+});
+
+describe("buildServiceRequestEmailHtml", () => {
+  it("carries the details needed to act without opening the app", () => {
+    const html = buildServiceRequestEmailHtml(emailInput());
+    expect(html).toContain("Brakes squeal when cold.");
+    expect(html).toContain("Volvo V70 (AB12345)");
+    expect(html).toContain("2026-08-10");
+    expect(html).toContain("kari@example.com");
+    expect(html).toContain("+47 900 00 000");
+    expect(html).toContain("https://app.example.com/customers/c1?tab=requests");
+  });
+
+  it("escapes a description written by a customer", () => {
+    const html = buildServiceRequestEmailHtml(
+      emailInput({ description: '<img src=x onerror="alert(1)">' }),
+    );
+    expect(html).not.toContain("<img src=x");
+    expect(html).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
+  });
+
+  it("escapes the customer name and vehicle too", () => {
+    const html = buildServiceRequestEmailHtml(
+      emailInput({ customerName: "<b>Kari</b>", vehicleLabel: "<i>V70</i>" }),
+    );
+    expect(html).not.toContain("<b>Kari</b>");
+    expect(html).not.toContain("<i>V70</i>");
+  });
+
+  it("omits rows the customer never filled in", () => {
+    const html = buildServiceRequestEmailHtml(
+      emailInput({ customerEmail: null, customerPhone: null, preferredDate: null }),
+    );
+    expect(html).not.toContain("Preferred date");
+    expect(html).not.toContain("Phone");
+    expect(html).toContain("Customer");
+  });
+
+  it("omits the link when no base URL is configured", () => {
+    const html = buildServiceRequestEmailHtml(emailInput({ requestUrl: null }));
+    expect(html).not.toContain("<a href");
+    // The rest of the mail still stands on its own.
+    expect(html).toContain("Brakes squeal when cold.");
+  });
+
+  it("truncates a description long enough to trouble a mail provider", () => {
+    const html = buildServiceRequestEmailHtml(
+      emailInput({ description: "x".repeat(MAX_DESCRIPTION_IN_EMAIL + 500) }),
+    );
+    expect(html).toContain("…");
+    expect(html).not.toContain("x".repeat(MAX_DESCRIPTION_IN_EMAIL + 1));
+  });
+});

+ 110 - 1
src/app/(authenticated)/settings/alerts/alerts-settings.tsx

@@ -14,7 +14,8 @@ import { toast } from "sonner";
 import { setSettings } from "@/features/settings/Actions/settingsActions";
 import { runLowStockCheck } from "@/features/inventory/Actions/runLowStockCheck";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { Loader2, PackageSearch, PlayCircle, Save } from "lucide-react";
+import { parseInvalidRecipients } from "@/features/portal/Lib/serviceRequestAlert";
+import { Loader2, PackageSearch, PlayCircle, Save, Wrench } from "lucide-react";
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from "../read-only-guard";
 
 /**
@@ -36,6 +37,7 @@ export function AlertsSettings({ settings }: { settings: Record<string, string>
       </div>
       <ReadOnlyWrapper>
         <div className="space-y-6">
+          <ServiceRequestAlertCard settings={settings} />
           <LowStockAlertCard settings={settings} />
         </div>
       </ReadOnlyWrapper>
@@ -43,6 +45,113 @@ export function AlertsSettings({ settings }: { settings: Record<string, string>
   );
 }
 
+function ServiceRequestAlertCard({ settings }: { settings: Record<string, string> }) {
+  const router = useRouter();
+  const t = useTranslations("settings");
+  const [saving, setSaving] = useState(false);
+
+  const [email, setEmail] = useState(
+    settings[SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL] === "true",
+  );
+  const [recipients, setRecipients] = useState(
+    settings[SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS] || "",
+  );
+
+  // Addresses are validated on save rather than while typing, so the field does
+  // not flag a half-written address on every keystroke.
+  const invalid = parseInvalidRecipients(recipients);
+
+  const handleSave = async () => {
+    if (email && invalid.length > 0) {
+      toast.error(t("alerts.serviceRequest.invalidRecipients", { list: invalid.join(", ") }));
+      return;
+    }
+    setSaving(true);
+    await setSettings({
+      [SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL]: email ? "true" : "false",
+      [SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS]: recipients.trim(),
+    });
+    setSaving(false);
+    router.refresh();
+    toast.success(t("alerts.serviceRequest.saved"));
+  };
+
+  return (
+    <Card className="border-0 shadow-sm">
+      <CardHeader className="flex flex-row items-center justify-between pb-4">
+        <div className="flex items-center gap-3">
+          <Wrench className="h-5 w-5 text-muted-foreground" />
+          <CardTitle className="text-lg">{t("alerts.serviceRequest.title")}</CardTitle>
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-6">
+        <p className="text-sm text-muted-foreground">
+          {t("alerts.serviceRequest.description")}
+        </p>
+
+        <div className="flex items-center justify-between">
+          <div className="space-y-0.5 pr-4">
+            <Label htmlFor="serviceRequestEmail">{t("alerts.serviceRequest.email")}</Label>
+            <p className="text-xs text-muted-foreground">
+              {t("alerts.serviceRequest.emailHint")}
+            </p>
+          </div>
+          <Switch
+            id="serviceRequestEmail"
+            checked={email}
+            onCheckedChange={setEmail}
+          />
+        </div>
+
+        {email && (
+          <>
+            <Separator />
+
+            <div className="space-y-2">
+              <Label htmlFor="serviceRequestRecipients">
+                {t("alerts.serviceRequest.recipients")}
+              </Label>
+              <Input
+                id="serviceRequestRecipients"
+                type="text"
+                placeholder="service@example.com, owner@example.com"
+                value={recipients}
+                onChange={(e) => setRecipients(e.target.value)}
+              />
+              <p className="text-xs text-muted-foreground">
+                {t("alerts.serviceRequest.recipientsHint")}
+              </p>
+              {invalid.length > 0 && (
+                <p className="text-xs text-destructive">
+                  {t("alerts.serviceRequest.invalidRecipients", { list: invalid.join(", ") })}
+                </p>
+              )}
+            </div>
+
+            <p className="text-xs text-muted-foreground">
+              {t("alerts.serviceRequest.emailSetupHint")}{" "}
+              <Link href="/settings/email" className="text-primary hover:underline">
+                {t("alerts.serviceRequest.emailSetupLink")}
+              </Link>
+            </p>
+          </>
+        )}
+
+        <SaveButton>
+          <Button onClick={handleSave} disabled={saving}>
+            {saving ? (
+              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+            ) : (
+              <Save className="mr-2 h-4 w-4" />
+            )}
+            {t("alerts.serviceRequest.save")}
+          </Button>
+        </SaveButton>
+      </CardContent>
+    </Card>
+  );
+}
+
 function LowStockAlertCard({ settings }: { settings: Record<string, string> }) {
   const router = useRouter();
   const t = useTranslations("settings");

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

@@ -9,6 +9,8 @@ export default async function AlertsSettingsPage() {
     SETTING_KEYS.LOW_STOCK_ALERTS_IN_APP,
     SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL,
     SETTING_KEYS.LOW_STOCK_ALERTS_EMAIL_MIN_INTERVAL_HOURS,
+    SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL,
+    SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS,
   ]);
   const settings = result.success && result.data ? result.data : {};
 

+ 28 - 2
src/features/portal/Actions/portalActions.ts

@@ -3,6 +3,7 @@
 import { db } from '@/lib/db'
 import { getCustomerSession, type CustomerSessionData } from '@/lib/customer-session'
 import { notify } from '@/lib/notify'
+import { sendServiceRequestAlert } from '@/features/portal/Lib/sendServiceRequestAlert'
 import { withAuth, type ActionResult } from '@/lib/with-auth'
 import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -320,7 +321,7 @@ export async function createServiceRequest(input: {
     // Validate vehicle belongs to customer
     const vehicle = await db.vehicle.findFirst({
       where: { id: input.vehicleId, customerId, organizationId },
-      select: { id: true, make: true, model: true },
+      select: { id: true, make: true, model: true, licensePlate: true },
     })
 
     if (!vehicle) {
@@ -329,7 +330,7 @@ export async function createServiceRequest(input: {
 
     const customer = await db.customer.findUnique({
       where: { id: customerId },
-      select: { name: true },
+      select: { name: true, email: true, phone: true },
     })
 
     const serviceRequest = await db.serviceRequest.create({
@@ -345,6 +346,11 @@ export async function createServiceRequest(input: {
     // Notify staff
     const vehicleName = `${vehicle.make} ${vehicle.model}`
     const customerName = customer?.name ?? 'A customer'
+    // The plate is what the workshop actually looks a car up by, so it goes in
+    // the email even though the in-app message stays short.
+    const vehicleLabel = vehicle.licensePlate
+      ? `${vehicleName} (${vehicle.licensePlate})`
+      : vehicleName
 
     await notify({
       organizationId,
@@ -356,6 +362,26 @@ export async function createServiceRequest(input: {
       entityUrl: `/customers/${customerId}?tab=requests`,
     })
 
+    // Email the workshop, if it asked to be emailed. Isolated on purpose: the
+    // request is committed and the bell has already rung, so a misconfigured
+    // mail provider must not surface to the customer as a failed submission.
+    // sendServiceRequestAlert swallows its own errors; this is the backstop for
+    // anything it cannot anticipate.
+    try {
+      await sendServiceRequestAlert({
+        organizationId,
+        customerId,
+        customerName,
+        customerEmail: customer?.email ?? null,
+        customerPhone: customer?.phone ?? null,
+        vehicleLabel,
+        description: input.description,
+        preferredDate: serviceRequest.preferredDate,
+      })
+    } catch (error) {
+      console.error('[createServiceRequest] alert email failed:', error)
+    }
+
     return serviceRequest
   })
 }

+ 15 - 1
src/features/portal/Components/CustomerPortalSettings.tsx

@@ -17,7 +17,8 @@ import {
   CardHeader,
   CardTitle,
 } from '@/components/ui/card'
-import { Copy, Check, Globe, Loader2, Image as ImageIcon, Trash2, Upload } from 'lucide-react'
+import Link from 'next/link'
+import { Bell, Copy, Check, Globe, Loader2, Image as ImageIcon, Trash2, Upload } from 'lucide-react'
 import { ReadOnlyBanner, ReadOnlyWrapper } from '@/app/(authenticated)/settings/read-only-guard'
 import { setSetting, setSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -265,6 +266,19 @@ export function CustomerPortalSettings({
                   </div>
                   <p className="text-xs text-muted-foreground">{t('portal.shareHint')}</p>
                 </div>
+
+                {/* Requests are submitted here but the alert that announces them
+                    is configured on another page. Without a pointer, an operator
+                    turning the portal on has no reason to go looking for it. */}
+                <div className="flex items-start gap-2 rounded-md border bg-muted/40 p-3">
+                  <Bell className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+                  <p className="text-xs text-muted-foreground">
+                    {t('portal.serviceRequestAlertsHint')}{' '}
+                    <Link href="/settings/alerts" className="text-primary hover:underline">
+                      {t('portal.serviceRequestAlertsLink')}
+                    </Link>
+                  </p>
+                </div>
               </>
             )}
           </CardContent>

+ 160 - 0
src/features/portal/Lib/sendServiceRequestAlert.ts

@@ -0,0 +1,160 @@
+/**
+ * Delivery half of the service request alert.
+ *
+ * Everything here is best-effort. By the time this runs the request row is
+ * already committed and the in-app notification has already fired, so a
+ * workshop with no mail provider configured must never see its customers'
+ * submissions fail. Nothing in this module is allowed to throw.
+ */
+
+import { db } from "@/lib/db";
+import { getOrgFromAddress, sendOrgMail } from "@/lib/email";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import {
+  buildServiceRequestEmailHtml,
+  buildServiceRequestSubject,
+  formatPreferredDate,
+  parseRecipientList,
+} from "./serviceRequestAlert";
+
+const LOG_PREFIX = "[service-request-alert]";
+
+/** Fallback recipients when no addresses are configured. */
+async function getDefaultRecipients(organizationId: string): Promise<string[]> {
+  const members = await db.organizationMember.findMany({
+    where: { organizationId, role: { in: ["owner", "admin"] } },
+    select: { user: { select: { email: true } } },
+  });
+
+  return parseRecipientList(
+    members
+      .map((m) => m.user?.email)
+      .filter(Boolean)
+      .join(","),
+  );
+}
+
+export interface ServiceRequestAlertInput {
+  organizationId: string;
+  customerId: string;
+  customerName: string;
+  customerEmail: string | null;
+  customerPhone: string | null;
+  vehicleLabel: string;
+  description: string;
+  preferredDate: Date | null;
+}
+
+/**
+ * Mail the workshop about a new service request.
+ *
+ * Returns the number of messages actually accepted by the provider, which is 0
+ * for every ordinary "not configured" case: the feature is off, nobody is
+ * listed, or no sender could be resolved. Callers use it for logging only.
+ */
+export async function sendServiceRequestAlert(
+  input: ServiceRequestAlertInput,
+): Promise<number> {
+  try {
+    const settingRows = await db.appSetting.findMany({
+      where: {
+        organizationId: input.organizationId,
+        key: {
+          in: [
+            SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL,
+            SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS,
+          ],
+        },
+      },
+      select: { key: true, value: true },
+    });
+
+    const settings = new Map(settingRows.map((r) => [r.key, r.value]));
+    if (settings.get(SETTING_KEYS.SERVICE_REQUEST_ALERTS_EMAIL) !== "true") {
+      return 0;
+    }
+
+    // An explicit list wins outright. A workshop that routes these to a shared
+    // service@ mailbox does not also want them in every owner's personal inbox.
+    const configured = parseRecipientList(
+      settings.get(SETTING_KEYS.SERVICE_REQUEST_ALERTS_RECIPIENTS),
+    );
+    const recipients =
+      configured.length > 0
+        ? configured
+        : await getDefaultRecipients(input.organizationId);
+
+    if (recipients.length === 0) {
+      console.warn(
+        `${LOG_PREFIX} enabled for org ${input.organizationId} but no valid recipients; skipping`,
+      );
+      return 0;
+    }
+
+    const org = await db.organization
+      .findUnique({
+        where: { id: input.organizationId },
+        select: { name: true },
+      })
+      .catch(() => null);
+
+    // Resolving the sender reads the org's email provider settings, which is
+    // exactly what is missing when email was never set up. Without a sender
+    // there is no honest way to send, so stop here rather than invent one.
+    let fromAddress: string;
+    try {
+      fromAddress = await getOrgFromAddress(input.organizationId);
+    } catch (error) {
+      console.error(
+        `${LOG_PREFIX} could not resolve a sender address (email is likely not configured); skipping:`,
+        error,
+      );
+      return 0;
+    }
+
+    const appUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, "");
+    const organizationName = org?.name ?? "your workshop";
+
+    const subject = buildServiceRequestSubject({
+      customerName: input.customerName,
+      vehicleLabel: input.vehicleLabel,
+    });
+    const html = buildServiceRequestEmailHtml({
+      organizationName,
+      customerName: input.customerName,
+      customerEmail: input.customerEmail,
+      customerPhone: input.customerPhone,
+      vehicleLabel: input.vehicleLabel,
+      description: input.description,
+      preferredDate: formatPreferredDate(input.preferredDate),
+      requestUrl: appUrl
+        ? `${appUrl}/customers/${input.customerId}?tab=requests`
+        : null,
+    });
+
+    // One message per recipient rather than a shared To line, so staff
+    // addresses are not disclosed to each other, and so one bad address costs
+    // only its own send.
+    let sent = 0;
+    for (const recipient of recipients) {
+      try {
+        await sendOrgMail(input.organizationId, {
+          to: recipient,
+          from: fromAddress,
+          subject,
+          html,
+        });
+        sent++;
+      } catch (error) {
+        console.error(`${LOG_PREFIX} email to ${recipient} failed:`, error);
+      }
+    }
+
+    return sent;
+  } catch (error) {
+    // Settings lookup, member lookup, anything unforeseen. The request is
+    // already saved; losing the email is the acceptable outcome here.
+    console.error(`${LOG_PREFIX} alert failed:`, error);
+    return 0;
+  }
+}

+ 151 - 0
src/features/portal/Lib/serviceRequestAlert.ts

@@ -0,0 +1,151 @@
+/**
+ * Recipient parsing and message building for the "new service request" alert.
+ *
+ * Kept free of Next.js and Prisma imports so the rules that decide who is
+ * mailed, and what the workshop ends up reading, can be tested directly rather
+ * than through a server action.
+ */
+
+import { escapeHtml } from "@/features/support/Lib/supportRequest";
+
+/**
+ * Deliberately loose. This is a last line of defence against a typo reaching
+ * the mail provider, not an attempt to decide what a valid address is — that
+ * argument is unwinnable and the strict patterns people reach for reject real
+ * addresses. Anything with a local part, an @ and a dotted domain passes.
+ */
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+/**
+ * How much of the customer's description travels in the email body.
+ *
+ * The full text is always one click away in the app. A portal form with no
+ * client-side limit can carry a very long paste, and mail providers start
+ * truncating or rejecting oversized HTML bodies.
+ */
+export const MAX_DESCRIPTION_IN_EMAIL = 4000;
+
+/**
+ * Split the free-text recipient field into addresses.
+ *
+ * Operators type this by hand, so commas, semicolons, newlines and stray
+ * spaces all turn up. Entries that are not shaped like an address are dropped
+ * rather than handed to the provider: most reject the entire message when one
+ * recipient is malformed, which would take the valid recipients down too.
+ */
+export function parseRecipientList(raw: string | null | undefined): string[] {
+  if (!raw) return [];
+
+  const seen = new Set<string>();
+  const addresses: string[] = [];
+
+  for (const candidate of raw.split(/[,;\s]+/)) {
+    const address = candidate.trim();
+    if (!address || !EMAIL_PATTERN.test(address)) continue;
+
+    // Case-insensitive dedupe — the same person written two ways should not
+    // receive the alert twice.
+    const key = address.toLowerCase();
+    if (seen.has(key)) continue;
+
+    seen.add(key);
+    addresses.push(address);
+  }
+
+  return addresses;
+}
+
+/**
+ * The entries `parseRecipientList` would silently drop.
+ *
+ * Shares the pattern above deliberately. The settings form warns with this, the
+ * sender filters with that — if the two ever disagreed, an operator could save
+ * an address the form accepted and never learn it was skipped at send time.
+ */
+export function parseInvalidRecipients(raw: string | null | undefined): string[] {
+  if (!raw) return [];
+  return raw
+    .split(/[,;\s]+/)
+    .map((candidate) => candidate.trim())
+    .filter((candidate) => candidate.length > 0 && !EMAIL_PATTERN.test(candidate));
+}
+
+/** A date with no time attached; the customer picks a day, not an hour. */
+export function formatPreferredDate(date: Date | null | undefined): string | null {
+  if (!date || Number.isNaN(date.getTime())) return null;
+  // ISO day rather than a locale format: the recipient's locale is unknown
+  // here, and 03/08 reads as two different days on either side of the Atlantic.
+  return date.toISOString().slice(0, 10);
+}
+
+export interface ServiceRequestEmailInput {
+  organizationName: string;
+  customerName: string;
+  customerEmail: string | null;
+  customerPhone: string | null;
+  vehicleLabel: string;
+  description: string;
+  preferredDate: string | null;
+  /** Absolute link into the app, or null when no base URL is configured. */
+  requestUrl: string | null;
+}
+
+export function buildServiceRequestSubject(input: {
+  customerName: string;
+  vehicleLabel: string;
+}): string {
+  return `New service request from ${input.customerName} (${input.vehicleLabel})`;
+}
+
+/**
+ * The point of this email is that the workshop can act on it without opening
+ * the app first: who asked, for which vehicle, when they want it, and how to
+ * reach them all travel in the body. The link is for the reply, not the read.
+ */
+export function buildServiceRequestEmailHtml(input: ServiceRequestEmailInput): string {
+  const description =
+    input.description.length > MAX_DESCRIPTION_IN_EMAIL
+      ? `${input.description.slice(0, MAX_DESCRIPTION_IN_EMAIL)}…`
+      : input.description;
+
+  const rows: [string, string | null][] = [
+    ["Customer", input.customerName],
+    ["Vehicle", input.vehicleLabel],
+    ["Preferred date", input.preferredDate],
+    ["Email", input.customerEmail],
+    ["Phone", input.customerPhone],
+  ];
+
+  const detailRows = rows
+    .filter(([, value]) => value)
+    .map(
+      ([label, value]) =>
+        `<tr>
+          <td style="padding:4px 12px 4px 0;color:#666;vertical-align:top;white-space:nowrap;">${escapeHtml(label)}</td>
+          <td style="padding:4px 0;color:#111;word-break:break-word;">${escapeHtml(value as string)}</td>
+        </tr>`,
+    )
+    .join("");
+
+  const link = input.requestUrl
+    ? `<p style="margin:20px 0 0;">
+         <a href="${escapeHtml(input.requestUrl)}" style="color:#2563eb;">Open in ${escapeHtml(input.organizationName)}</a>
+       </p>`
+    : "";
+
+  return `
+    <div style="font-family: sans-serif; max-width: 640px; margin: 0 auto;">
+      <h2 style="margin:0 0 4px;">New service request</h2>
+      <p style="margin:0 0 16px;color:#666;font-size:13px;">
+        Submitted through the customer portal at ${escapeHtml(input.organizationName)}
+      </p>
+      <table style="font-size:13px;border-collapse:collapse;margin-bottom:16px;">${detailRows}</table>
+      <div style="white-space:pre-wrap;line-height:1.5;color:#111;">${escapeHtml(description)}</div>
+      ${link}
+      <hr style="border:none;border-top:1px solid #eee;margin:20px 0;" />
+      <p style="margin:0;color:#666;font-size:12px;">
+        You are receiving this because service request emails are enabled in Settings → Alerts.
+      </p>
+    </div>
+  `;
+}

+ 7 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -96,6 +96,13 @@ export const SETTING_KEYS = {
   PORTAL_ENABLED: 'portal.enabled',
   PORTAL_DESCRIPTION: 'portal.description',
   PORTAL_HOURS: 'portal.hours',
+  /// Off by default. Every service request already raises an in-app
+  /// notification; this adds email on top, and stays opt-in because a workshop
+  /// with no mail provider configured would otherwise generate failed sends.
+  SERVICE_REQUEST_ALERTS_EMAIL: 'portal.serviceRequestAlerts.email',
+  /// Optional free-text list of addresses. Empty means the alert goes to every
+  /// owner and admin in the organization.
+  SERVICE_REQUEST_ALERTS_RECIPIENTS: 'portal.serviceRequestAlerts.recipients',
   PORTAL_BACKGROUND_TYPE: 'portal.background.type',
   PORTAL_BACKGROUND_TEMPLATE: 'portal.background.template',
   PORTAL_BACKGROUND_IMAGE: 'portal.background.image',