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

Let a disconnected mail vendor fall back to platform mail and reshape the email composer (#477)

Bernt Christian Egeland 3 дней назад
Родитель
Сommit
f166ece10f

+ 1 - 0
messages/de/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Antwort schreiben",
     "subjectPlaceholder": "Betreff",
     "send": "Senden",
+    "sendHint": "Enter sendet, Shift+Enter beginnt eine neue Zeile",
     "sendFailed": "E-Mail konnte nicht gesendet werden",
     "sentDocument": "Gesendet: {subject}",
     "showQuoted": "Zitierten Text anzeigen",

+ 1 - 0
messages/en/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Write a reply…",
     "subjectPlaceholder": "Subject",
     "send": "Send",
+    "sendHint": "Enter sends, Shift+Enter starts a new line",
     "sendFailed": "Could not send the email",
     "sentDocument": "Sent: {subject}",
     "showQuoted": "Show quoted text",

+ 1 - 0
messages/es/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Escribe una respuesta",
     "subjectPlaceholder": "Asunto",
     "send": "Enviar",
+    "sendHint": "Enter envía, Shift+Enter añade una línea",
     "sendFailed": "No se pudo enviar el correo",
     "sentDocument": "Enviado: {subject}",
     "showQuoted": "Mostrar texto citado",

+ 1 - 0
messages/fr/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Écrire une réponse",
     "subjectPlaceholder": "Objet",
     "send": "Envoyer",
+    "sendHint": "Entrée envoie, Maj+Entrée ajoute une ligne",
     "sendFailed": "Impossible d'envoyer l'e-mail",
     "sentDocument": "Envoyé : {subject}",
     "showQuoted": "Afficher le texte cité",

+ 1 - 0
messages/it/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Scrivi una risposta",
     "subjectPlaceholder": "Oggetto",
     "send": "Invia",
+    "sendHint": "Invio invia, Maiusc+Invio va a capo",
     "sendFailed": "Impossibile inviare l'email",
     "sentDocument": "Inviato: {subject}",
     "showQuoted": "Mostra testo citato",

+ 1 - 0
messages/lt/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Rašykite atsakymą",
     "subjectPlaceholder": "Tema",
     "send": "Siųsti",
+    "sendHint": "Enter išsiunčia, Shift+Enter prideda eilutę",
     "sendFailed": "Nepavyko išsiųsti el. laiško",
     "sentDocument": "Išsiųsta: {subject}",
     "showQuoted": "Rodyti cituojamą tekstą",

+ 1 - 0
messages/nb/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Skriv et svar",
     "subjectPlaceholder": "Emne",
     "send": "Send",
+    "sendHint": "Enter sender, Shift+Enter gir ny linje",
     "sendFailed": "Kunne ikke sende e-posten",
     "sentDocument": "Sendt: {subject}",
     "showQuoted": "Vis sitert tekst",

+ 1 - 0
messages/nl/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Schrijf een antwoord",
     "subjectPlaceholder": "Onderwerp",
     "send": "Versturen",
+    "sendHint": "Enter verstuurt, Shift+Enter begint een nieuwe regel",
     "sendFailed": "Kon de e-mail niet versturen",
     "sentDocument": "Verstuurd: {subject}",
     "showQuoted": "Geciteerde tekst tonen",

+ 1 - 0
messages/pl/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Napisz odpowiedź",
     "subjectPlaceholder": "Temat",
     "send": "Wyślij",
+    "sendHint": "Enter wysyła, Shift+Enter dodaje nową linię",
     "sendFailed": "Nie udało się wysłać e-maila",
     "sentDocument": "Wysłano: {subject}",
     "showQuoted": "Pokaż cytowany tekst",

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

@@ -33,6 +33,7 @@
     "placeholder": "Escreva uma resposta",
     "subjectPlaceholder": "Assunto",
     "send": "Enviar",
+    "sendHint": "Enter envia, Shift+Enter adiciona uma linha",
     "sendFailed": "Não foi possível enviar o e-mail",
     "sentDocument": "Enviado: {subject}",
     "showQuoted": "Mostrar texto citado",

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

@@ -33,6 +33,7 @@
     "placeholder": "Escreva uma resposta",
     "subjectPlaceholder": "Assunto",
     "send": "Enviar",
+    "sendHint": "Enter envia, Shift+Enter adiciona uma linha",
     "sendFailed": "Não foi possível enviar o e-mail",
     "sentDocument": "Enviado: {subject}",
     "showQuoted": "Mostrar texto citado",

+ 1 - 0
messages/ru/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Напишите ответ",
     "subjectPlaceholder": "Тема",
     "send": "Отправить",
+    "sendHint": "Enter отправляет, Shift+Enter добавляет строку",
     "sendFailed": "Не удалось отправить письмо",
     "sentDocument": "Отправлено: {subject}",
     "showQuoted": "Показать цитату",

+ 1 - 0
messages/tr/messaging.json

@@ -33,6 +33,7 @@
     "placeholder": "Bir yanıt yazın",
     "subjectPlaceholder": "Konu",
     "send": "Gönder",
+    "sendHint": "Enter gönderir, Shift+Enter yeni satır ekler",
     "sendFailed": "E-posta gönderilemedi",
     "sentDocument": "Gönderildi: {subject}",
     "showQuoted": "Alıntılanan metni göster",

+ 13 - 2
src/__tests__/features/integrations/messaging.test.ts

@@ -447,12 +447,23 @@ describe('one vendor per channel', () => {
   })
 
   it('names the vendor an unfinished email setup pointed at', async () => {
-    appSetting.findUnique.mockResolvedValue({ value: 'mailgun' })
+    appSetting.findMany.mockResolvedValue([{ key: 'email.provider', value: 'mailgun' }])
     expect(await legacyProviderNamed('org-15', 'email')).toBe('mailgun')
-    appSetting.findUnique.mockResolvedValue({ value: '' })
+    appSetting.findMany.mockResolvedValue([{ key: 'email.provider', value: '' }])
     expect(await legacyProviderNamed('org-15', 'email')).toBeNull()
     expect(await legacyProviderNamed('org-15', 'telegram')).toBeNull()
   })
+
+  it('stops naming the vendor once the setup was adopted and then disconnected', async () => {
+    // The old provider row is never cleared; the marker says the connection
+    // took over, so a vendor the workshop since disconnected no longer blocks
+    // platform mail.
+    appSetting.findMany.mockResolvedValue([
+      { key: 'email.provider', value: 'smtp' },
+      { key: adoptedMarkerKey('email'), value: '2026-10-30T00:00:00.000Z' },
+    ])
+    expect(await legacyProviderNamed('org-16', 'email')).toBeNull()
+  })
 })
 
 describe('inbound webhook lookup', () => {

+ 13 - 7
src/features/integrations/Lib/messaging.ts

@@ -271,9 +271,13 @@ export async function legacySetupFor(
 }
 
 /**
- * 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.
+ * Whether the old rows still name a vendor for the channel. 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.
+ *
+ * Once the setup has been adopted, the connection is the truth and the old
+ * row is history: a workshop that disconnected its vendor afterwards sends
+ * from the platform again, and the row it never cleared must not stop it.
  */
 export async function legacyProviderNamed(
   organizationId: string,
@@ -281,11 +285,13 @@ export async function legacyProviderNamed(
 ): 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 marker = adoptedMarkerKey(channel)
+  const rows = await db.appSetting.findMany({
+    where: { organizationId, key: { in: [key, marker] } },
+    select: { key: true, value: true },
   })
-  const named = row?.value?.trim()
+  if (rows.some((row) => row.key === marker)) return null
+  const named = rows.find((row) => row.key === key)?.value?.trim()
   return named && providerForLegacyId(channel, named) ? named : null
 }
 

+ 36 - 25
src/features/messaging/Components/EmailConversation.tsx

@@ -14,7 +14,6 @@ import {
   ShieldAlert,
 } from 'lucide-react'
 import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
 import { cn } from '@/lib/utils'
 import type { EmailChannelState } from '../Actions/inboxActions'
@@ -227,46 +226,58 @@ export function EmailConversation({
       </div>
 
       {emailState === 'active' && (
-        <div className="shrink-0 space-y-2 border-t bg-background p-4">
+        <div className="shrink-0 border-t bg-background p-3 sm:p-4">
           {!thread.customerId ? (
             <p className="text-sm text-muted-foreground">{t('unknownSender')}</p>
           ) : (
-            <>
-              <Input
-                value={subject}
-                onChange={(event) => setSubjectDraft(event.target.value)}
-                placeholder={t('subjectPlaceholder')}
-                maxLength={200}
-                className="h-9"
-              />
-              <div className="flex items-end gap-2">
-                <Textarea
-                  value={body}
-                  onChange={(event) => setBody(event.target.value)}
-                  placeholder={t('placeholder')}
-                  rows={2}
-                  className="min-h-[44px] flex-1 resize-none"
-                  onKeyDown={(event) => {
-                    if (event.key === 'Enter' && !event.shiftKey) {
-                      event.preventDefault()
-                      handleSend()
-                    }
-                  }}
+            // One card shaped like the mail it becomes: the subject line on
+            // top, the body under it, and the send control in a footer, so
+            // the subject is not mistaken for a search box above a chat.
+            <div className="overflow-hidden rounded-xl border bg-card shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50">
+              <label className="flex items-center gap-3 border-b px-3">
+                <span className="shrink-0 text-xs font-medium text-muted-foreground">
+                  {t('subjectPlaceholder')}
+                </span>
+                <input
+                  value={subject}
+                  onChange={(event) => setSubjectDraft(event.target.value)}
+                  maxLength={200}
+                  className="h-9 min-w-0 flex-1 bg-transparent text-sm outline-none"
                 />
+              </label>
+              <Textarea
+                value={body}
+                onChange={(event) => setBody(event.target.value)}
+                placeholder={t('placeholder')}
+                rows={3}
+                className="max-h-60 min-h-[76px] resize-none rounded-none border-0 shadow-none focus-visible:ring-0 dark:bg-transparent"
+                onKeyDown={(event) => {
+                  if (event.key === 'Enter' && !event.shiftKey) {
+                    event.preventDefault()
+                    handleSend()
+                  }
+                }}
+              />
+              <div className="flex items-center justify-between gap-3 border-t bg-muted/40 px-3 py-2">
+                <span className="hidden truncate text-[11px] text-muted-foreground sm:block">
+                  {t('sendHint')}
+                </span>
                 <Button
                   type="button"
+                  size="sm"
                   onClick={handleSend}
                   disabled={isSending || !body.trim()}
-                  aria-label={t('send')}
+                  className="ml-auto"
                 >
                   {isSending ? (
                     <Loader2 className="h-4 w-4 animate-spin" />
                   ) : (
                     <Send className="h-4 w-4" />
                   )}
+                  {t('send')}
                 </Button>
               </div>
-            </>
+            </div>
           )}
         </div>
       )}