fix(composer): validate recipients (#1310)

FromSi and Andriy Chernov created

## What?

Added recipient validation in the composer for `To`, `Cc`, and `Bcc`
fields.

- Validate email fields with `mail.ParseAddress()` when leaving a
recipient field.
- Normalize display-name addresses to plain email addresses after
validation.
- Clear inline validation errors when the user edits the field again.
- Block sending when recipient fields are invalid or when no recipients
are provided.
- Show a composer notice for send-time validation problems.
- Added i18n keys for invalid recipient errors.

<img width="1400" height="800" alt="view"
src="https://github.com/user-attachments/assets/aa69d838-b5ab-48cf-ba89-bab79d1f69c6"
/>


## Why?

This prevents confusing SMTP failures caused by invalid recipient input
and gives users immediate feedback inside the composer.

Closes #648
Closes #734
---------

Signed-off-by: drew <me@andrinoff.com>
Co-authored-by: Andriy Chernov <andriy@floatpane.com>

Change summary

i18n/locales/ar.json |   5 
i18n/locales/de.json |   5 
i18n/locales/en.json |   5 
i18n/locales/es.json |   5 
i18n/locales/fr.json |   5 
i18n/locales/ja.json |   5 
i18n/locales/pl.json |   5 
i18n/locales/pt.json |   5 
i18n/locales/ru.json |   5 
i18n/locales/uk.json |   5 
i18n/locales/zh.json |   5 
tui/composer.go      | 177 ++++++++++++++++++++++++++++++++++++++++++
tui/composer_test.go | 188 +++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 407 insertions(+), 13 deletions(-)

Detailed changes

i18n/locales/ar.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "هل أنت متأكد أنك تريد الخروج؟ سيتم حفظ هذه المسودة",
       "sending": "جاري إرسال البريد الإلكتروني...",
       "sent": "تم إرسال البريد الإلكتروني بنجاح",
-      "draft_saved": "تم حفظ المسودة"
+      "draft_saved": "تم حفظ المسودة",
+      "invalid_email": "✗ عنوان بريد إلكتروني غير صالح",
+      "invalid_email_fields": "حقل بريد إلكتروني واحد أو أكثر غير صالح",
+      "recipient_required": "أضف مستلمًا واحدًا على الأقل"
     },
     "inbox": {
       "title": "صندوق الوارد",

i18n/locales/de.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "Sind Sie sicher, dass Sie beenden möchten? Dieser Entwurf wird gespeichert",
       "sending": "E-Mail wird gesendet...",
       "sent": "E-Mail erfolgreich gesendet",
-      "draft_saved": "Entwurf gespeichert"
+      "draft_saved": "Entwurf gespeichert",
+      "invalid_email": "✗ Ungültige E-Mail-Adresse",
+      "invalid_email_fields": "Ein oder mehrere E-Mail-Felder sind ungültig",
+      "recipient_required": "Mindestens einen Empfänger hinzufügen"
     },
     "inbox": {
       "title": "Posteingang",

i18n/locales/en.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "Are you sure you want to exit? This draft will be saved",
       "sending": "Sending email...",
       "sent": "Email sent successfully",
-      "draft_saved": "Draft saved"
+      "draft_saved": "Draft saved",
+      "invalid_email": "✗ Invalid email address",
+      "invalid_email_fields": "One or more email fields are invalid",
+      "recipient_required": "Add at least one recipient"
     },
     "inbox": {
       "title": "Inbox",

i18n/locales/es.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "¿Está seguro de que desea salir? Este borrador se guardará",
       "sending": "Enviando correo...",
       "sent": "Correo enviado exitosamente",
-      "draft_saved": "Borrador guardado"
+      "draft_saved": "Borrador guardado",
+      "invalid_email": "✗ Dirección de correo no válida",
+      "invalid_email_fields": "Uno o más campos de correo son inválidos",
+      "recipient_required": "Añade al menos un destinatario"
     },
     "inbox": {
       "title": "Bandeja de entrada",

i18n/locales/fr.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "Êtes-vous sûr de vouloir quitter ? Ce brouillon sera sauvegardé",
       "sending": "Envoi de l'e-mail...",
       "sent": "E-mail envoyé avec succès",
-      "draft_saved": "Brouillon sauvegardé"
+      "draft_saved": "Brouillon sauvegardé",
+      "invalid_email": "✗ Adresse e-mail invalide",
+      "invalid_email_fields": "Un ou plusieurs champs e-mail sont invalides",
+      "recipient_required": "Ajoutez au moins un destinataire"
     },
     "inbox": {
       "title": "Boîte de réception",

i18n/locales/ja.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "終了してもよろしいですか?この下書きは保存されます",
       "sending": "メール送信中...",
       "sent": "メールが正常に送信されました",
-      "draft_saved": "下書きを保存しました"
+      "draft_saved": "下書きを保存しました",
+      "invalid_email": "✗ 無効なメールアドレス",
+      "invalid_email_fields": "1つ以上のメールフィールドが無効です",
+      "recipient_required": "少なくとも1人の宛先を追加してください"
     },
     "inbox": {
       "title": "受信トレイ",

i18n/locales/pl.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "Czy na pewno chcesz wyjść? Ten szkic zostanie zapisany",
       "sending": "Wysyłanie wiadomości...",
       "sent": "Wiadomość wysłana pomyślnie",
-      "draft_saved": "Szkic zapisany"
+      "draft_saved": "Szkic zapisany",
+      "invalid_email": "✗ Nieprawidłowy adres e-mail",
+      "invalid_email_fields": "Jedno lub więcej pól e-mail jest nieprawidłowych",
+      "recipient_required": "Dodaj co najmniej jednego odbiorcę"
     },
     "inbox": {
       "title": "Skrzynka odbiorcza",

i18n/locales/pt.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "Tem certeza de que deseja sair? Este rascunho será salvo",
       "sending": "Enviando e-mail...",
       "sent": "E-mail enviado com sucesso",
-      "draft_saved": "Rascunho salvo"
+      "draft_saved": "Rascunho salvo",
+      "invalid_email": "✗ Endereço de e-mail inválido",
+      "invalid_email_fields": "Um ou mais campos de e-mail são inválidos",
+      "recipient_required": "Adicione pelo menos um destinatário"
     },
     "inbox": {
       "title": "Caixa de entrada",

i18n/locales/ru.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "Вы уверены, что хотите выйти? Этот черновик будет сохранён",
       "sending": "Отправка письма...",
       "sent": "Письмо успешно отправлено",
-      "draft_saved": "Черновик сохранён"
+      "draft_saved": "Черновик сохранён",
+      "invalid_email": "✗ Неверный адрес электронной почты",
+      "invalid_email_fields": "Одно или несколько полей электронной почты недействительны",
+      "recipient_required": "Добавьте хотя бы одного получателя"
     },
     "inbox": {
       "title": "Входящие",

i18n/locales/uk.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "Ви впевнені, що хочете вийти? Цей чернетку буде збережено",
       "sending": "Відправлення листа...",
       "sent": "Лист успішно надіслано",
-      "draft_saved": "Чернетку збережено"
+      "draft_saved": "Чернетку збережено",
+      "invalid_email": "✗ Недійсна електронна адреса",
+      "invalid_email_fields": "Одне або кілька полів електронної пошти недійсні",
+      "recipient_required": "Додайте принаймні одного отримувача"
     },
     "inbox": {
       "title": "Вхідні",

i18n/locales/zh.json 🔗

@@ -39,7 +39,10 @@
       "exit_confirm": "确定要退出吗?此草稿将被保存",
       "sending": "正在发送邮件...",
       "sent": "邮件发送成功",
-      "draft_saved": "草稿已保存"
+      "draft_saved": "草稿已保存",
+      "invalid_email": "✗ 无效的电子邮件地址",
+      "invalid_email_fields": "一个或多个电子邮件字段无效",
+      "recipient_required": "请至少添加一个收件人"
     },
     "inbox": {
       "title": "收件箱",

tui/composer.go 🔗

@@ -2,9 +2,11 @@ package tui
 
 import (
 	"fmt"
+	"net/mail"
 	"os"
 	"path/filepath"
 	"strings"
+	"time"
 
 	"charm.land/bubbles/v2/textarea"
 	"charm.land/bubbles/v2/textinput"
@@ -30,6 +32,7 @@ var (
 	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
 	fromSelectorStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 	smimeToggleStyle    = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
+	composerErrorStyle  = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("196"))
 )
 
 const (
@@ -45,12 +48,18 @@ const (
 	focusSend
 )
 
+type hideComposerNoticeMsg struct{}
+
 // Composer model holds the state of the email composition UI.
 type Composer struct {
 	focusIndex       int
 	toInput          textinput.Model
 	ccInput          textinput.Model
 	bccInput         textinput.Model
+	fromError        string
+	toError          string
+	ccError          string
+	bccError         string
 	subjectInput     textinput.Model
 	bodyInput        textarea.Model
 	signatureInput   textarea.Model
@@ -61,6 +70,8 @@ type Composer struct {
 	width            int
 	height           int
 	confirmingExit   bool
+	showNotice       bool
+	noticeText       string
 	hideTips         bool
 
 	// Multi-account support
@@ -159,6 +170,100 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 	return m
 }
 
+func normalizeEmailList(value string) (string, bool) {
+	value = strings.TrimSpace(value)
+	if value == "" {
+		return "", true
+	}
+
+	parts := strings.Split(value, ",")
+	addresses := make([]string, 0, len(parts))
+	for _, part := range parts {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+		addr, err := mail.ParseAddress(part)
+		if err != nil || addr.Address == "" {
+			return value, false
+		}
+		addresses = append(addresses, addr.Address)
+	}
+	if len(addresses) == 0 {
+		return "", true
+	}
+	return strings.Join(addresses, ", "), true
+}
+
+func (m *Composer) hasAnyRecipient() bool {
+	return strings.TrimSpace(m.toInput.Value()) != "" ||
+		strings.TrimSpace(m.ccInput.Value()) != "" ||
+		strings.TrimSpace(m.bccInput.Value()) != ""
+}
+
+func (m *Composer) showComposerNotice(message string) tea.Cmd {
+	m.noticeText = message
+	m.showNotice = true
+	return tea.Tick(5*time.Second, func(time.Time) tea.Msg {
+		return hideComposerNoticeMsg{}
+	})
+}
+
+func (m *Composer) hideComposerNotice() {
+	m.showNotice = false
+	m.noticeText = ""
+}
+
+func (m *Composer) validateFromField() bool {
+	if !m.isCatchAllAccount() {
+		m.fromError = ""
+		return true
+	}
+	value := strings.TrimSpace(m.fromInput.Value())
+	addr, err := mail.ParseAddress(value)
+	if value == "" || err != nil || addr.Address == "" {
+		m.fromError = t("composer.invalid_email")
+		return false
+	}
+	m.fromError = ""
+	return true
+}
+
+func (m *Composer) validateEmailField(focus int) bool {
+	var input *textinput.Model
+	var setError func(string)
+	switch focus {
+	case focusTo:
+		input = &m.toInput
+		setError = func(err string) { m.toError = err }
+	case focusCc:
+		input = &m.ccInput
+		setError = func(err string) { m.ccError = err }
+	case focusBcc:
+		input = &m.bccInput
+		setError = func(err string) { m.bccError = err }
+	default:
+		return true
+	}
+
+	normalized, ok := normalizeEmailList(input.Value())
+	if !ok {
+		setError(t("composer.invalid_email"))
+		return false
+	}
+	input.SetValue(normalized)
+	setError("")
+	return true
+}
+
+func (m *Composer) canSendEmail() bool {
+	m.validateFromField()
+	m.validateEmailField(focusTo)
+	m.validateEmailField(focusCc)
+	m.validateEmailField(focusBcc)
+	return m.fromError == "" && m.toError == "" && m.ccError == "" && m.bccError == ""
+}
+
 // updateSignature updates the signature input based on the current selected account.
 func (m *Composer) updateSignature() {
 	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
@@ -342,6 +447,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.signatureInput.SetHeight(sigHeight)
 		}
 
+	case hideComposerNoticeMsg:
+		m.hideComposerNotice()
+		return m, nil
+
 	case FileSelectedMsg:
 		// Avoid duplicates and add all selected paths
 		for _, newPath := range msg.Paths {
@@ -406,6 +515,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 				m.toInput.SetValue(finalValue)
 				m.toInput.SetCursor(len(finalValue))
+				m.toError = ""
 				m.lastToValue = m.toInput.Value()
 				m.showSuggestions = false
 				m.suggestions = nil
@@ -471,6 +581,14 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 
+		if m.showNotice {
+			switch msg.String() {
+			case "enter", "esc", " ":
+				m.hideComposerNotice()
+			}
+			return m, nil
+		}
+
 		kb := config.Keybinds
 		attachmentPathSize := len(m.attachmentPaths)
 		if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
@@ -494,6 +612,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, nil
 
 		case kb.Composer.NextField, kb.Composer.PrevField:
+			previousFocus := m.focusIndex
 			if msg.String() == kb.Composer.PrevField {
 				m.focusIndex--
 			} else {
@@ -513,6 +632,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.focusIndex = maxFocus
 			}
 
+			if previousFocus == focusFrom {
+				m.validateFromField()
+			} else if previousFocus != m.focusIndex {
+				m.validateEmailField(previousFocus)
+			}
+
 			m.fromInput.Blur()
 			m.toInput.Blur()
 			m.ccInput.Blur()
@@ -570,6 +695,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 			case focusSend:
 				if msg.String() == "enter" {
+					if !m.canSendEmail() {
+						return m, m.showComposerNotice(t("composer.invalid_email_fields"))
+					}
+					if !m.hasAnyRecipient() {
+						return m, m.showComposerNotice(t("composer.recipient_required"))
+					}
 					acc := m.getSelectedAccount()
 					accountID := ""
 					if acc != nil {
@@ -606,16 +737,24 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch m.focusIndex {
 	case focusFrom:
 		if m.isCatchAllAccount() {
+			previousFromValue := m.fromInput.Value()
 			m.fromInput, cmd = m.fromInput.Update(msg)
 			cmds = append(cmds, cmd)
+			if m.fromInput.Value() != previousFromValue {
+				m.fromError = ""
+			}
 		}
 	case focusTo:
+		previousToValue := m.toInput.Value()
 		m.toInput, cmd = m.toInput.Update(msg)
 		cmds = append(cmds, cmd)
 
 		// Check if To field value changed and update suggestions
 		currentValue := m.toInput.Value()
 		if currentValue != m.lastToValue {
+			if currentValue != previousToValue {
+				m.toError = ""
+			}
 			m.lastToValue = currentValue
 
 			// Extract the last comma-separated part for searching
@@ -632,11 +771,19 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	case focusCc:
+		previousCcValue := m.ccInput.Value()
 		m.ccInput, cmd = m.ccInput.Update(msg)
 		cmds = append(cmds, cmd)
+		if m.ccInput.Value() != previousCcValue {
+			m.ccError = ""
+		}
 	case focusBcc:
+		previousBccValue := m.bccInput.Value()
 		m.bccInput, cmd = m.bccInput.Update(msg)
 		cmds = append(cmds, cmd)
+		if m.bccInput.Value() != previousBccValue {
+			m.bccError = ""
+		}
 	case focusSubject:
 		m.subjectInput, cmd = m.subjectInput.Update(msg)
 		cmds = append(cmds, cmd)
@@ -676,6 +823,9 @@ func (m *Composer) View() tea.View {
 		} else {
 			fromField = "  " + t("composer.from") + " " + fromAddrView
 		}
+		if m.fromError != "" {
+			fromField += "\n" + composerErrorStyle.Render(m.fromError)
+		}
 	} else if len(m.accounts) > 1 {
 		if m.focusIndex == focusFrom {
 			fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
@@ -729,6 +879,9 @@ func (m *Composer) View() tea.View {
 
 	// Build To field with suggestions
 	toFieldView := m.toInput.View()
+	if m.toError != "" {
+		toFieldView += "\n" + composerErrorStyle.Render(m.toError)
+	}
 	if m.showSuggestions && len(m.suggestions) > 0 {
 		var suggestionsBuilder strings.Builder
 		suggestionWidth := suggestionDisplayWidth(m.width)
@@ -743,6 +896,16 @@ func (m *Composer) View() tea.View {
 		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
 	}
 
+	ccFieldView := m.ccInput.View()
+	if m.ccError != "" {
+		ccFieldView += "\n" + composerErrorStyle.Render(m.ccError)
+	}
+
+	bccFieldView := m.bccInput.View()
+	if m.bccError != "" {
+		bccFieldView += "\n" + composerErrorStyle.Render(m.bccError)
+	}
+
 	// Signature field label
 	var signatureLabel string
 	if m.focusIndex == focusSignature {
@@ -779,8 +942,8 @@ func (m *Composer) View() tea.View {
 		t("composer.title"),
 		fromField,
 		toFieldView,
-		m.ccInput.View(),
-		m.bccInput.View(),
+		ccFieldView,
+		bccFieldView,
 		m.subjectInput.View(),
 		m.bodyInput.View(),
 		signatureLabel,
@@ -872,6 +1035,16 @@ func (m *Composer) View() tea.View {
 		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
 	}
 
+	if m.showNotice {
+		dialog := DialogBoxStyle.Render(
+			lipgloss.JoinVertical(lipgloss.Center,
+				dangerStyle.Render(m.noticeText),
+				HelpStyle.Render("\nenter/esc: close"),
+			),
+		)
+		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
+	}
+
 	return tea.NewView(composerView.String())
 }
 

tui/composer_test.go 🔗

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	tea "charm.land/bubbletea/v2"
@@ -39,6 +40,193 @@ func TestMailingListSuggestionTruncates(t *testing.T) {
 	}
 }
 
+func TestNormalizeEmailList(t *testing.T) {
+	got, ok := normalizeEmailList("Alice Example <alice@example.com>, bob@example.com")
+	if !ok {
+		t.Fatal("Expected valid email list")
+	}
+	if want := "alice@example.com, bob@example.com"; got != want {
+		t.Fatalf("normalizeEmailList() = %q, want %q", got, want)
+	}
+
+	if _, ok := normalizeEmailList("not-an-email"); ok {
+		t.Fatal("Expected invalid email list")
+	}
+}
+
+func TestComposerEmailValidationOnFieldBlur(t *testing.T) {
+	composer := NewComposer("", "", "", "", false)
+	composer.toInput.SetValue("not-an-email")
+
+	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
+	composer = model.(*Composer)
+
+	if composer.toError == "" {
+		t.Fatal("Expected To validation error after leaving invalid field")
+	}
+	if !strings.Contains(fmt.Sprint(composer.View()), composer.toError) {
+		t.Fatal("Expected validation error to be rendered below To field")
+	}
+}
+
+func TestComposerFromValidationOnFieldBlur(t *testing.T) {
+	tests := []struct {
+		name      string
+		from      string
+		wantError bool
+	}{
+		{
+			name:      "invalid from",
+			from:      "not-an-email",
+			wantError: true,
+		},
+		{
+			name: "bare address",
+			from: "user@example.org",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			accounts := []config.Account{
+				{ID: "account-1", Email: "user@example.org", CatchAll: true},
+			}
+			composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
+			composer.focusIndex = focusFrom
+			composer.fromInput.Focus()
+			composer.fromInput.SetValue(tt.from)
+
+			model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
+			composer = model.(*Composer)
+
+			if tt.wantError {
+				if composer.fromError == "" {
+					t.Fatal("Expected From validation error after leaving invalid catch-all From field")
+				}
+				if !strings.Contains(fmt.Sprint(composer.View()), composer.fromError) {
+					t.Fatal("Expected From validation error to be rendered below From field")
+				}
+				return
+			}
+			if composer.fromError != "" {
+				t.Fatalf("Expected From address to be valid, got %q", composer.fromError)
+			}
+		})
+	}
+}
+
+func TestComposerEmailValidationClearsWhenTyping(t *testing.T) {
+	composer := NewComposer("", "", "", "", false)
+	composer.toInput.SetValue("not-an-email")
+
+	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
+	composer = model.(*Composer)
+	if composer.toError == "" {
+		t.Fatal("Expected To validation error after leaving invalid field")
+	}
+
+	composer.focusIndex = focusTo
+	composer.toInput.Focus()
+	model, _ = composer.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})
+	composer = model.(*Composer)
+
+	if composer.toError != "" {
+		t.Fatalf("Expected To validation error to clear when typing, got %q", composer.toError)
+	}
+}
+
+func TestComposerSendValidatesEmailFields(t *testing.T) {
+	tests := []struct {
+		name          string
+		to            string
+		cc            string
+		catchAllFrom  string
+		wantCcError   bool
+		wantFromError bool
+	}{
+		{
+			name:        "invalid cc",
+			to:          "recipient@example.com",
+			cc:          "not-an-email",
+			wantCcError: true,
+		},
+		{
+			name:          "invalid catch-all from",
+			to:            "recipient@example.com",
+			catchAllFrom:  "not-an-email",
+			wantFromError: true,
+		},
+		{
+			name: "no recipients",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var composer *Composer
+			if tt.catchAllFrom != "" {
+				accounts := []config.Account{
+					{ID: "account-1", Email: "user@example.org", CatchAll: true},
+				}
+				composer = NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
+				composer.fromInput.SetValue(tt.catchAllFrom)
+			} else {
+				composer = NewComposer("", "", "", "", false)
+			}
+			composer.toInput.SetValue(tt.to)
+			composer.ccInput.SetValue(tt.cc)
+			composer.subjectInput.SetValue("Test Subject")
+			composer.bodyInput.SetValue("This is the body.")
+			composer.focusIndex = focusSend
+
+			model, cmd := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+			composer = model.(*Composer)
+
+			if cmd == nil {
+				t.Fatal("Expected auto-close command for composer notice")
+			}
+			if !composer.showNotice {
+				t.Fatal("Expected composer notice to be shown after send attempt")
+			}
+			if tt.wantCcError && composer.ccError == "" {
+				t.Fatal("Expected Cc validation error after send attempt")
+			}
+			if tt.wantFromError && composer.fromError == "" {
+				t.Fatal("Expected From validation error after send attempt")
+			}
+
+			model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+			composer = model.(*Composer)
+
+			if composer.showNotice {
+				t.Fatal("Expected composer notice to close on Enter")
+			}
+			if tt.wantCcError && !strings.Contains(fmt.Sprint(composer.View()), composer.ccError) {
+				t.Fatal("Expected Cc validation error to be rendered after closing notice")
+			}
+			if tt.wantFromError && !strings.Contains(fmt.Sprint(composer.View()), composer.fromError) {
+				t.Fatal("Expected From validation error to be rendered after closing notice")
+			}
+		})
+	}
+}
+
+func TestComposerContactSuggestionUsesDisplayName(t *testing.T) {
+	composer := NewComposer("", "", "", "", false)
+	composer.showSuggestions = true
+	composer.suggestions = []config.Contact{{
+		Name:  "Alice Example",
+		Email: "alice@example.com",
+	}}
+
+	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	composer = model.(*Composer)
+
+	if got, want := composer.toInput.Value(), "Alice Example <alice@example.com>, "; got != want {
+		t.Fatalf("Expected suggestion to insert display-name address, got %q, want %q", got, want)
+	}
+}
+
 // TestComposerUpdate verifies the state transitions in the email composer.
 func TestComposerUpdate(t *testing.T) {
 	// Initialize a new composer with accounts.