diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index d6818784de9d3e53f33ae06e4ac77ac39b9bdc1a..3107724b1220cae5192f182fb1b139a81663ec05 100644 --- a/i18n/locales/ar.json +++ b/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": "صندوق الوارد", diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 8fc87d3a20822bd59429164b5bc13f019339dc55..d07d0244df05b5c206bdced2c3d375ab988ae98a 100644 --- a/i18n/locales/de.json +++ b/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", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 8d0c465eb051b81e0cf8af9c2a65f092cde301bd..48355f98ca933a992d0e5c306c4707d3bf4216e4 100644 --- a/i18n/locales/en.json +++ b/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", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 1bc563a750d852a017d7cec81481daf612cd054b..91a4f626336255c67ddf0f83e3ab23085d408b3a 100644 --- a/i18n/locales/es.json +++ b/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", diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index 62220e5b7e0217f36f0f52a1913a5a8ae76ef9eb..286469dbb482138e112d3fe99dbbb3b104a46c49 100644 --- a/i18n/locales/fr.json +++ b/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", diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index f5892f73e40facb20ea2152ccf66a318df998033..cb0859eed075ae7d9a36acc8cdd889a633d9c6a2 100644 --- a/i18n/locales/ja.json +++ b/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": "受信トレイ", diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json index 2c8a82a8aa6d7d89293e062ebc6ec27c2f8f213c..1e1ba18cbf5bdce631c5fc3af521aac0735b5ced 100644 --- a/i18n/locales/pl.json +++ b/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", diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json index 3d6562713940e76cac7696d3d311785e01b2a214..2227661107af991f175188e29da360bac1474420 100644 --- a/i18n/locales/pt.json +++ b/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", diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index f18e8c666089cbbc1930c078e78f0dbc65e1114b..3d86353c514df1e1434ed8a59ab668ba7b2816b6 100644 --- a/i18n/locales/ru.json +++ b/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": "Входящие", diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json index 37a597edb62fd8939df0a6600fbd8a2cc7a85d3c..740e5ca32be53a8af47e9913051d5ec898617d0a 100644 --- a/i18n/locales/uk.json +++ b/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": "Вхідні", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 276af2f81f1e7878a0e9251c5ec9f6ee1ff40c96..fa8266ae300f78a74c8e5773db22bd19529fc41d 100644 --- a/i18n/locales/zh.json +++ b/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": "收件箱", diff --git a/tui/composer.go b/tui/composer.go index 07ace935b551e7f90c8f87fbdc4b128e5a5f8f8e..8ad8db24a24818bc0260226e3e3eba9071e3b9d9 100644 --- a/tui/composer.go +++ b/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()) } diff --git a/tui/composer_test.go b/tui/composer_test.go index 6dc91574e81ff41a4adef40298ce40bf2a5c1a85..a9cb65c937ea0210cd3d40c9d6e05ad862142659 100644 --- a/tui/composer_test.go +++ b/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 , 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 , "; 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.