Detailed changes
@@ -39,7 +39,10 @@
"exit_confirm": "هل أنت متأكد أنك تريد الخروج؟ سيتم حفظ هذه المسودة",
"sending": "جاري إرسال البريد الإلكتروني...",
"sent": "تم إرسال البريد الإلكتروني بنجاح",
- "draft_saved": "تم حفظ المسودة"
+ "draft_saved": "تم حفظ المسودة",
+ "invalid_email": "✗ عنوان بريد إلكتروني غير صالح",
+ "invalid_email_fields": "حقل بريد إلكتروني واحد أو أكثر غير صالح",
+ "recipient_required": "أضف مستلمًا واحدًا على الأقل"
},
"inbox": {
"title": "صندوق الوارد",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -39,7 +39,10 @@
"exit_confirm": "終了してもよろしいですか?この下書きは保存されます",
"sending": "メール送信中...",
"sent": "メールが正常に送信されました",
- "draft_saved": "下書きを保存しました"
+ "draft_saved": "下書きを保存しました",
+ "invalid_email": "✗ 無効なメールアドレス",
+ "invalid_email_fields": "1つ以上のメールフィールドが無効です",
+ "recipient_required": "少なくとも1人の宛先を追加してください"
},
"inbox": {
"title": "受信トレイ",
@@ -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",
@@ -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",
@@ -39,7 +39,10 @@
"exit_confirm": "Вы уверены, что хотите выйти? Этот черновик будет сохранён",
"sending": "Отправка письма...",
"sent": "Письмо успешно отправлено",
- "draft_saved": "Черновик сохранён"
+ "draft_saved": "Черновик сохранён",
+ "invalid_email": "✗ Неверный адрес электронной почты",
+ "invalid_email_fields": "Одно или несколько полей электронной почты недействительны",
+ "recipient_required": "Добавьте хотя бы одного получателя"
},
"inbox": {
"title": "Входящие",
@@ -39,7 +39,10 @@
"exit_confirm": "Ви впевнені, що хочете вийти? Цей чернетку буде збережено",
"sending": "Відправлення листа...",
"sent": "Лист успішно надіслано",
- "draft_saved": "Чернетку збережено"
+ "draft_saved": "Чернетку збережено",
+ "invalid_email": "✗ Недійсна електронна адреса",
+ "invalid_email_fields": "Одне або кілька полів електронної пошти недійсні",
+ "recipient_required": "Додайте принаймні одного отримувача"
},
"inbox": {
"title": "Вхідні",
@@ -39,7 +39,10 @@
"exit_confirm": "确定要退出吗?此草稿将被保存",
"sending": "正在发送邮件...",
"sent": "邮件发送成功",
- "draft_saved": "草稿已保存"
+ "draft_saved": "草稿已保存",
+ "invalid_email": "✗ 无效的电子邮件地址",
+ "invalid_email_fields": "一个或多个电子邮件字段无效",
+ "recipient_required": "请至少添加一个收件人"
},
"inbox": {
"title": "收件箱",
@@ -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())
}
@@ -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.