composer.go

   1package tui
   2
   3import (
   4	"fmt"
   5	"net/mail"
   6	"os"
   7	"path/filepath"
   8	"strings"
   9	"time"
  10
  11	"charm.land/bubbles/v2/textarea"
  12	"charm.land/bubbles/v2/textinput"
  13	tea "charm.land/bubbletea/v2"
  14	"charm.land/lipgloss/v2"
  15	"github.com/floatpane/matcha/config"
  16	"github.com/google/uuid"
  17)
  18
  19var (
  20	suggestionStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  21	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  22	suggestionBoxStyle      = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
  23)
  24
  25// Styles for the UI
  26var (
  27	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
  28	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  29	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  30	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  31	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
  32	smimeToggleStyle    = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
  33	composerErrorStyle  = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("196"))
  34)
  35
  36const (
  37	focusFrom = iota
  38	focusTo
  39	focusCc
  40	focusBcc
  41	focusSubject
  42	focusBody
  43	focusSignature
  44	focusAttachment
  45	focusEncryptSMIME
  46	focusSend
  47)
  48
  49type hideComposerNoticeMsg struct{}
  50
  51// Composer model holds the state of the email composition UI.
  52type Composer struct {
  53	focusIndex       int
  54	toInput          textinput.Model
  55	ccInput          textinput.Model
  56	bccInput         textinput.Model
  57	fromError        string
  58	toError          string
  59	ccError          string
  60	bccError         string
  61	subjectInput     textinput.Model
  62	bodyInput        textarea.Model
  63	signatureInput   textarea.Model
  64	attachmentPaths  []string
  65	attachmentNames  map[string]string
  66	attachmentCursor int
  67	encryptSMIME     bool
  68	width            int
  69	height           int
  70	confirmingExit   bool
  71	showNotice       bool
  72	noticeText       string
  73	hideTips         bool
  74
  75	// Multi-account support
  76	accounts           []config.Account
  77	selectedAccountIdx int
  78	showAccountPicker  bool
  79	fromInput          textinput.Model // editable From when account is catch-all
  80
  81	// Contact suggestions
  82	suggestions        []config.Contact
  83	selectedSuggestion int
  84	showSuggestions    bool
  85	lastToValue        string
  86
  87	// Draft persistence
  88	draftID string
  89
  90	// Reply context
  91	inReplyTo  string
  92	references []string
  93
  94	// Hidden quoted text (appended to body when sending, but not shown in editor)
  95	quotedText string
  96
  97	// Plugin status text shown in the help bar
  98	pluginStatus      string
  99	pluginKeyBindings []PluginKeyBinding
 100
 101	// Plugin prompt overlay
 102	showPluginPrompt        bool
 103	pluginPromptInput       textinput.Model
 104	pluginPromptPlaceholder string
 105}
 106
 107// NewComposer initializes a new composer model.
 108func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 109	m := &Composer{
 110		draftID:         uuid.New().String(),
 111		hideTips:        hideTips,
 112		attachmentNames: make(map[string]string),
 113	}
 114
 115	tiStyles := ThemedTextInputStyles()
 116	taStyles := ThemedTextAreaStyles()
 117
 118	m.toInput = textinput.New()
 119	m.toInput.Placeholder = t("composer.to_placeholder")
 120	m.toInput.SetValue(to)
 121	m.toInput.Prompt = "> "
 122	m.toInput.CharLimit = 256
 123	m.toInput.SetStyles(tiStyles)
 124
 125	m.ccInput = textinput.New()
 126	m.ccInput.Placeholder = t("composer.cc_placeholder")
 127	m.ccInput.Prompt = "> "
 128	m.ccInput.CharLimit = 256
 129	m.ccInput.SetStyles(tiStyles)
 130
 131	m.bccInput = textinput.New()
 132	m.bccInput.Placeholder = t("composer.bcc_placeholder")
 133	m.bccInput.Prompt = "> "
 134	m.bccInput.CharLimit = 256
 135	m.bccInput.SetStyles(tiStyles)
 136
 137	m.subjectInput = textinput.New()
 138	m.subjectInput.Placeholder = t("composer.subject_placeholder")
 139	m.subjectInput.SetValue(subject)
 140	m.subjectInput.Prompt = "> "
 141	m.subjectInput.CharLimit = 256
 142	m.subjectInput.SetStyles(tiStyles)
 143
 144	m.bodyInput = textarea.New()
 145	m.bodyInput.Placeholder = t("composer.body_placeholder")
 146	m.bodyInput.SetValue(body)
 147	m.bodyInput.Prompt = "> "
 148	m.bodyInput.SetHeight(10)
 149	m.bodyInput.SetStyles(taStyles)
 150
 151	m.signatureInput = textarea.New()
 152	m.signatureInput.Placeholder = t("composer.signature_placeholder")
 153	m.signatureInput.Prompt = "> "
 154	m.signatureInput.SetHeight(3)
 155	m.signatureInput.SetStyles(taStyles)
 156	m.updateSignature()
 157
 158	m.fromInput = textinput.New()
 159	m.fromInput.Placeholder = t("composer.from_placeholder")
 160	m.fromInput.Prompt = "> "
 161	m.fromInput.CharLimit = 256
 162	m.fromInput.SetStyles(tiStyles)
 163
 164	// Start focus on To field (From is selectable but not a text input)
 165	m.focusIndex = focusTo
 166	m.toInput.Focus()
 167
 168	return m
 169}
 170
 171func normalizeEmailList(value string) (string, bool) {
 172	value = strings.TrimSpace(value)
 173	if value == "" {
 174		return "", true
 175	}
 176
 177	parts := strings.Split(value, ",")
 178	addresses := make([]string, 0, len(parts))
 179	for _, part := range parts {
 180		part = strings.TrimSpace(part)
 181		if part == "" {
 182			continue
 183		}
 184		addr, err := mail.ParseAddress(part)
 185		if err != nil || addr.Address == "" {
 186			return value, false
 187		}
 188		addresses = append(addresses, addr.Address)
 189	}
 190	if len(addresses) == 0 {
 191		return "", true
 192	}
 193	return strings.Join(addresses, ", "), true
 194}
 195
 196func (m *Composer) hasAnyRecipient() bool {
 197	return strings.TrimSpace(m.toInput.Value()) != "" ||
 198		strings.TrimSpace(m.ccInput.Value()) != "" ||
 199		strings.TrimSpace(m.bccInput.Value()) != ""
 200}
 201
 202func (m *Composer) showComposerNotice(message string) tea.Cmd {
 203	m.noticeText = message
 204	m.showNotice = true
 205	return tea.Tick(5*time.Second, func(time.Time) tea.Msg {
 206		return hideComposerNoticeMsg{}
 207	})
 208}
 209
 210func (m *Composer) hideComposerNotice() {
 211	m.showNotice = false
 212	m.noticeText = ""
 213}
 214
 215func (m *Composer) validateFromField() bool { //nolint:unparam
 216	if !m.isCatchAllAccount() {
 217		m.fromError = ""
 218		return true
 219	}
 220	value := strings.TrimSpace(m.fromInput.Value())
 221	addr, err := mail.ParseAddress(value)
 222	if value == "" || err != nil || addr.Address == "" {
 223		m.fromError = t("composer.invalid_email")
 224		return false
 225	}
 226	m.fromError = ""
 227	return true
 228}
 229
 230func (m *Composer) validateEmailField(focus int) bool { //nolint:unparam
 231	var input *textinput.Model
 232	var setError func(string)
 233	switch focus {
 234	case focusTo:
 235		input = &m.toInput
 236		setError = func(err string) { m.toError = err }
 237	case focusCc:
 238		input = &m.ccInput
 239		setError = func(err string) { m.ccError = err }
 240	case focusBcc:
 241		input = &m.bccInput
 242		setError = func(err string) { m.bccError = err }
 243	default:
 244		return true
 245	}
 246
 247	normalized, ok := normalizeEmailList(input.Value())
 248	if !ok {
 249		setError(t("composer.invalid_email"))
 250		return false
 251	}
 252	input.SetValue(normalized)
 253	setError("")
 254	return true
 255}
 256
 257func (m *Composer) canSendEmail() bool {
 258	m.validateFromField()
 259	m.validateEmailField(focusTo)
 260	m.validateEmailField(focusCc)
 261	m.validateEmailField(focusBcc)
 262	return m.fromError == "" && m.toError == "" && m.ccError == "" && m.bccError == ""
 263}
 264
 265// updateSignature updates the signature input based on the current selected account.
 266func (m *Composer) updateSignature() {
 267	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 268		acc := &m.accounts[m.selectedAccountIdx]
 269		if sig, err := config.LoadSignatureForAccount(acc); err == nil && sig != "" {
 270			m.signatureInput.SetValue(sig)
 271		} else if sig, err := config.LoadSignature(); err == nil && sig != "" {
 272			m.signatureInput.SetValue(sig)
 273		} else {
 274			m.signatureInput.SetValue("")
 275		}
 276		// Seed the editable From address for catch-all accounts.
 277		m.fromInput.SetValue(acc.FormatFromHeader())
 278		return
 279	}
 280
 281	if sig, err := config.LoadSignature(); err == nil && sig != "" {
 282		m.signatureInput.SetValue(sig)
 283	} else {
 284		m.signatureInput.SetValue("")
 285	}
 286}
 287
 288// NewComposerWithAccounts initializes a composer with multiple account support.
 289func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
 290	m := NewComposer("", to, subject, body, hideTips)
 291	m.accounts = accounts
 292
 293	// Find the selected account index
 294	for i, acc := range accounts {
 295		if acc.ID == selectedAccountID {
 296			m.selectedAccountIdx = i
 297			break
 298		}
 299	}
 300	m.updateSignature()
 301
 302	return m
 303}
 304
 305// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
 306func (m *Composer) ResetConfirmation() {
 307	m.confirmingExit = false
 308}
 309
 310// SetFromOverride pre-fills the editable From field (used for catch-all replies).
 311func (m *Composer) SetFromOverride(addr string) {
 312	m.fromInput.SetValue(addr)
 313}
 314
 315func (m *Composer) Init() tea.Cmd {
 316	return textinput.Blink
 317}
 318
 319func (m *Composer) getFromAddress() string {
 320	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 321		return m.accounts[m.selectedAccountIdx].FormatFromHeader()
 322	}
 323	return ""
 324}
 325
 326func (m *Composer) isCatchAllAccount() bool {
 327	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 328		return m.accounts[m.selectedAccountIdx].CatchAll
 329	}
 330	return false
 331}
 332
 333func (m *Composer) getSelectedAccount() *config.Account {
 334	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 335		return &m.accounts[m.selectedAccountIdx]
 336	}
 337	return nil
 338}
 339
 340func formatAttachmentName(path string) string {
 341	name := filepath.Base(path)
 342	info, err := os.Stat(path)
 343	if err != nil || info.IsDir() {
 344		return name
 345	}
 346	return fmt.Sprintf("%s (%s)", name, tfs(info.Size()))
 347}
 348
 349func (m *Composer) attachmentDisplayName(path string) string {
 350	if name, ok := m.attachmentNames[path]; ok {
 351		return name
 352	}
 353	return filepath.Base(path)
 354}
 355
 356func (m *Composer) clampAttachmentCursor() {
 357	if len(m.attachmentPaths) == 0 {
 358		m.attachmentCursor = 0
 359		return
 360	}
 361	if m.attachmentCursor < 0 {
 362		m.attachmentCursor = 0
 363	}
 364	if m.attachmentCursor >= len(m.attachmentPaths) {
 365		m.attachmentCursor = len(m.attachmentPaths) - 1
 366	}
 367}
 368
 369func (m *Composer) removeSelectedAttachment() {
 370	if len(m.attachmentPaths) == 0 {
 371		return
 372	}
 373
 374	m.clampAttachmentCursor()
 375	idx := m.attachmentCursor
 376	delete(m.attachmentNames, m.attachmentPaths[idx])
 377	m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...)
 378	m.clampAttachmentCursor()
 379}
 380
 381func suggestionDisplay(s config.Contact, suggestionWidth int) string {
 382	display := s.Email
 383	if len(s.Addresses) > 0 {
 384		display = fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Addresses, ", "))
 385		return truncateSuggestionDisplay(display, suggestionWidth)
 386	} else if s.Name != "" && s.Name != s.Email {
 387		display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
 388	}
 389	return display
 390}
 391
 392func suggestionDisplayWidth(width int) int {
 393	if width > 12 {
 394		return width - 6
 395	}
 396	return 40
 397}
 398
 399func truncateSuggestionDisplay(s string, maxLen int) string {
 400	runes := []rune(s)
 401	if len(runes) <= maxLen {
 402		return s
 403	}
 404	if maxLen <= 0 {
 405		return ""
 406	}
 407	if maxLen <= 3 {
 408		return string(runes[:maxLen])
 409	}
 410	return string(runes[:maxLen-3]) + "..."
 411}
 412
 413func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 414	var cmds []tea.Cmd
 415	var cmd tea.Cmd
 416
 417	switch msg := msg.(type) {
 418	case tea.WindowSizeMsg:
 419		m.width = msg.Width
 420		m.height = msg.Height
 421		inputWidth := msg.Width - 6
 422		m.toInput.SetWidth(inputWidth)
 423		m.ccInput.SetWidth(inputWidth)
 424		m.bccInput.SetWidth(inputWidth)
 425		m.subjectInput.SetWidth(inputWidth)
 426		m.bodyInput.SetWidth(inputWidth)
 427		m.signatureInput.SetWidth(inputWidth)
 428		if msg.Height > 0 {
 429			// Fixed rows: title, from, to, cc, bcc, subject, sig label,
 430			// attachment, smime, button, blank, tip, help = 13
 431			const fixedRows = 13
 432			available := msg.Height - fixedRows
 433			if available < 6 {
 434				available = 6
 435			}
 436			bodyHeight := (available * 55) / 100
 437			sigHeight := (available * 15) / 100
 438			if bodyHeight < 3 {
 439				bodyHeight = 3
 440			}
 441			if sigHeight < 2 {
 442				sigHeight = 2
 443			}
 444			m.bodyInput.SetHeight(bodyHeight)
 445			m.signatureInput.SetHeight(sigHeight)
 446		}
 447
 448	case hideComposerNoticeMsg:
 449		m.hideComposerNotice()
 450		return m, nil
 451
 452	case FileSelectedMsg:
 453		// Avoid duplicates and add all selected paths
 454		for _, newPath := range msg.Paths {
 455			exists := false
 456			for _, p := range m.attachmentPaths {
 457				if p == newPath {
 458					exists = true
 459					break
 460				}
 461			}
 462			if !exists {
 463				m.attachmentPaths = append(m.attachmentPaths, newPath)
 464				m.attachmentNames[newPath] = formatAttachmentName(newPath)
 465			}
 466		}
 467		m.clampAttachmentCursor()
 468		return m, nil
 469
 470	case tea.KeyPressMsg:
 471		// Handle contact suggestions mode
 472		if m.showSuggestions && len(m.suggestions) > 0 {
 473			switch msg.String() {
 474			case "up", "ctrl+p":
 475				if m.selectedSuggestion > 0 {
 476					m.selectedSuggestion--
 477				}
 478				return m, nil
 479			case keyDown, "ctrl+n":
 480				if m.selectedSuggestion < len(m.suggestions)-1 {
 481					m.selectedSuggestion++
 482				}
 483				return m, nil
 484			case "tab", keyEnter:
 485				// Select the suggestion
 486				selected := m.suggestions[m.selectedSuggestion]
 487
 488				var newEmail string
 489				switch {
 490				case len(selected.Addresses) > 0:
 491					// Mailing list: emit just the addresses to maintain valid email formatting
 492					newEmail = strings.Join(selected.Addresses, ", ")
 493				case selected.Name != "" && selected.Name != selected.Email:
 494					newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
 495				default:
 496					newEmail = selected.Email
 497				}
 498
 499				parts := strings.Split(m.toInput.Value(), ",")
 500				if len(parts) > 0 {
 501					if len(parts) == 1 {
 502						parts[0] = newEmail
 503					} else {
 504						parts[len(parts)-1] = " " + newEmail
 505					}
 506				} else {
 507					parts = []string{newEmail}
 508				}
 509
 510				finalValue := strings.Join(parts, ",")
 511				if !strings.HasSuffix(finalValue, ", ") {
 512					finalValue += ", "
 513				}
 514
 515				m.toInput.SetValue(finalValue)
 516				m.toInput.SetCursor(len(finalValue))
 517				m.toError = ""
 518				m.lastToValue = m.toInput.Value()
 519				m.showSuggestions = false
 520				m.suggestions = nil
 521				return m, nil
 522			case "esc":
 523				m.showSuggestions = false
 524				m.suggestions = nil
 525				return m, nil
 526			}
 527			// For prev-field key, close suggestions and let it fall through to normal handling
 528			if msg.String() == config.Keybinds.Composer.PrevField {
 529				m.showSuggestions = false
 530				m.suggestions = nil
 531			}
 532		}
 533
 534		// Handle plugin prompt overlay
 535		if m.showPluginPrompt {
 536			switch msg.String() {
 537			case keyEnter:
 538				value := m.pluginPromptInput.Value()
 539				m.showPluginPrompt = false
 540				return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
 541			case "esc":
 542				m.showPluginPrompt = false
 543				return m, func() tea.Msg { return PluginPromptCancelMsg{} }
 544			default:
 545				m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
 546				return m, cmd
 547			}
 548		}
 549
 550		// Handle account picker mode
 551		if m.showAccountPicker {
 552			switch msg.String() {
 553			case "up", "k":
 554				if m.selectedAccountIdx > 0 {
 555					m.selectedAccountIdx--
 556					m.updateSignature()
 557				}
 558			case keyDown, "j":
 559				if m.selectedAccountIdx < len(m.accounts)-1 {
 560					m.selectedAccountIdx++
 561					m.updateSignature()
 562				}
 563			case keyEnter:
 564				m.showAccountPicker = false
 565			case "esc":
 566				m.showAccountPicker = false
 567			}
 568			return m, nil
 569		}
 570
 571		if m.confirmingExit {
 572			switch msg.String() {
 573			case "y", "Y":
 574				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
 575			case "n", "N", "esc":
 576				m.confirmingExit = false
 577				return m, nil
 578			default:
 579				return m, nil
 580			}
 581		}
 582
 583		if m.showNotice {
 584			switch msg.String() {
 585			case keyEnter, "esc", " ":
 586				m.hideComposerNotice()
 587			}
 588			return m, nil
 589		}
 590
 591		kb := config.Keybinds
 592		attachmentPathSize := len(m.attachmentPaths)
 593		if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
 594			switch msg.String() {
 595			case "up", kb.Global.NavUp:
 596				m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize
 597				return m, nil
 598			case keyDown, kb.Global.NavDown:
 599				m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize
 600				return m, nil
 601			}
 602		}
 603
 604		switch msg.String() {
 605		case kb.Global.Quit:
 606			return m, tea.Quit
 607		case kb.Composer.ExternalEditor:
 608			return m, func() tea.Msg { return OpenEditorMsg{} }
 609		case kb.Global.Cancel:
 610			m.confirmingExit = true
 611			return m, nil
 612
 613		case kb.Composer.NextField, kb.Composer.PrevField:
 614			previousFocus := m.focusIndex
 615			if msg.String() == kb.Composer.PrevField {
 616				m.focusIndex--
 617			} else {
 618				m.focusIndex++
 619			}
 620
 621			maxFocus := focusSend
 622			minFocus := focusFrom
 623			// Skip From field if only one non-catch-all account (nothing to switch or edit)
 624			if len(m.accounts) <= 1 && !m.isCatchAllAccount() {
 625				minFocus = focusTo
 626			}
 627
 628			if m.focusIndex > maxFocus {
 629				m.focusIndex = minFocus
 630			} else if m.focusIndex < minFocus {
 631				m.focusIndex = maxFocus
 632			}
 633
 634			if previousFocus == focusFrom {
 635				m.validateFromField()
 636			} else if previousFocus != m.focusIndex {
 637				m.validateEmailField(previousFocus)
 638			}
 639
 640			m.fromInput.Blur()
 641			m.toInput.Blur()
 642			m.ccInput.Blur()
 643			m.bccInput.Blur()
 644			m.subjectInput.Blur()
 645			m.bodyInput.Blur()
 646			m.signatureInput.Blur()
 647
 648			switch m.focusIndex {
 649			case focusFrom:
 650				if m.isCatchAllAccount() {
 651					cmds = append(cmds, m.fromInput.Focus())
 652				}
 653			case focusTo:
 654				cmds = append(cmds, m.toInput.Focus())
 655			case focusCc:
 656				cmds = append(cmds, m.ccInput.Focus())
 657			case focusBcc:
 658				cmds = append(cmds, m.bccInput.Focus())
 659			case focusSubject:
 660				cmds = append(cmds, m.subjectInput.Focus())
 661			case focusBody:
 662				cmds = append(cmds, m.bodyInput.Focus())
 663			case focusSignature:
 664				cmds = append(cmds, m.signatureInput.Focus())
 665			}
 666			return m, tea.Batch(cmds...)
 667
 668		case kb.Composer.Delete:
 669			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
 670				m.removeSelectedAttachment()
 671				return m, nil
 672			}
 673
 674		case keyEnter, " ":
 675			switch m.focusIndex {
 676			case focusFrom:
 677				if msg.String() == keyEnter && len(m.accounts) > 1 {
 678					m.showAccountPicker = true
 679					return m, nil
 680				}
 681				if m.isCatchAllAccount() && msg.String() == " " {
 682					break
 683				}
 684				return m, nil
 685			case focusAttachment:
 686				if msg.String() == keyEnter {
 687					return m, func() tea.Msg { return GoToFilePickerMsg{} }
 688				}
 689			case focusEncryptSMIME:
 690				if msg.String() == keyEnter || msg.String() == " " {
 691					m.encryptSMIME = !m.encryptSMIME
 692				}
 693				return m, nil
 694
 695			case focusSend:
 696				if msg.String() == keyEnter {
 697					if !m.canSendEmail() {
 698						return m, m.showComposerNotice(t("composer.invalid_email_fields"))
 699					}
 700					if !m.hasAnyRecipient() {
 701						return m, m.showComposerNotice(t("composer.recipient_required"))
 702					}
 703					acc := m.getSelectedAccount()
 704					accountID := ""
 705					if acc != nil {
 706						accountID = acc.ID
 707					}
 708					fromOverride := ""
 709					if m.isCatchAllAccount() {
 710						fromOverride = m.fromInput.Value()
 711					}
 712					return m, func() tea.Msg {
 713						return SendEmailMsg{
 714							To:              m.toInput.Value(),
 715							Cc:              m.ccInput.Value(),
 716							Bcc:             m.bccInput.Value(),
 717							Subject:         m.subjectInput.Value(),
 718							Body:            m.bodyInput.Value(),
 719							AttachmentPaths: m.attachmentPaths,
 720							AccountID:       accountID,
 721							FromOverride:    fromOverride,
 722							QuotedText:      m.quotedText,
 723							InReplyTo:       m.inReplyTo,
 724							References:      m.references,
 725							Signature:       m.signatureInput.Value(),
 726							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
 727							EncryptSMIME:    m.encryptSMIME,
 728							SignPGP:         acc != nil && acc.PGPSignByDefault,
 729						}
 730					}
 731				}
 732			}
 733		}
 734	}
 735
 736	switch m.focusIndex {
 737	case focusFrom:
 738		if m.isCatchAllAccount() {
 739			previousFromValue := m.fromInput.Value()
 740			m.fromInput, cmd = m.fromInput.Update(msg)
 741			cmds = append(cmds, cmd)
 742			if m.fromInput.Value() != previousFromValue {
 743				m.fromError = ""
 744			}
 745		}
 746	case focusTo:
 747		previousToValue := m.toInput.Value()
 748		m.toInput, cmd = m.toInput.Update(msg)
 749		cmds = append(cmds, cmd)
 750
 751		// Check if To field value changed and update suggestions
 752		currentValue := m.toInput.Value()
 753		if currentValue != m.lastToValue {
 754			if currentValue != previousToValue {
 755				m.toError = ""
 756			}
 757			m.lastToValue = currentValue
 758
 759			// Extract the last comma-separated part for searching
 760			parts := strings.Split(currentValue, ",")
 761			lastPart := strings.TrimSpace(parts[len(parts)-1])
 762
 763			if len(lastPart) >= 2 {
 764				m.suggestions = config.SearchContactsForAccount(lastPart, m.GetSelectedAccountID())
 765				m.showSuggestions = len(m.suggestions) > 0
 766				m.selectedSuggestion = 0
 767			} else {
 768				m.showSuggestions = false
 769				m.suggestions = nil
 770			}
 771		}
 772	case focusCc:
 773		previousCcValue := m.ccInput.Value()
 774		m.ccInput, cmd = m.ccInput.Update(msg)
 775		cmds = append(cmds, cmd)
 776		if m.ccInput.Value() != previousCcValue {
 777			m.ccError = ""
 778		}
 779	case focusBcc:
 780		previousBccValue := m.bccInput.Value()
 781		m.bccInput, cmd = m.bccInput.Update(msg)
 782		cmds = append(cmds, cmd)
 783		if m.bccInput.Value() != previousBccValue {
 784			m.bccError = ""
 785		}
 786	case focusSubject:
 787		m.subjectInput, cmd = m.subjectInput.Update(msg)
 788		cmds = append(cmds, cmd)
 789	case focusBody:
 790		m.bodyInput, cmd = m.bodyInput.Update(msg)
 791		cmds = append(cmds, cmd)
 792	case focusSignature:
 793		m.signatureInput, cmd = m.signatureInput.Update(msg)
 794		cmds = append(cmds, cmd)
 795	}
 796
 797	return m, tea.Batch(cmds...)
 798}
 799
 800func (m *Composer) View() tea.View { //nolint:gocyclo
 801	var composerView strings.Builder
 802	var button string
 803	ck := config.Keybinds.Composer
 804
 805	if m.focusIndex == focusSend {
 806		button = focusedStyle.Render("[ " + t("composer.send") + " ]")
 807	} else {
 808		button = blurredStyle.Render("[ " + t("composer.send") + " ]")
 809	}
 810
 811	// From field with account selector
 812	fromAddr := m.getFromAddress()
 813	var fromField string
 814	if m.isCatchAllAccount() { //nolint:gocritic
 815		fromAddrView := m.fromInput.View()
 816		if len(m.accounts) > 1 {
 817			if m.focusIndex == focusFrom {
 818				fromField = focusedStyle.Render(fmt.Sprintf("> %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.enter_to_switch")+"]")
 819			} else {
 820				fromField = blurredStyle.Render(fmt.Sprintf("  %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.switchable")+"]")
 821			}
 822		} else {
 823			fromField = "  " + t("composer.from") + " " + fromAddrView
 824		}
 825		if m.fromError != "" {
 826			fromField += "\n" + composerErrorStyle.Render(m.fromError)
 827		}
 828	} else if len(m.accounts) > 1 {
 829		if m.focusIndex == focusFrom {
 830			fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
 831		} else {
 832			fromField = blurredStyle.Render(fmt.Sprintf("  %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
 833		}
 834	} else if fromAddr != "" {
 835		fromField = "  " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
 836	} else {
 837		fromField = blurredStyle.Render(fmt.Sprintf("  %s (%s)", t("composer.from"), t("composer.no_account")))
 838	}
 839
 840	var attachmentField string
 841	if len(m.attachmentPaths) == 0 {
 842		attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
 843		if m.focusIndex == focusAttachment {
 844			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
 845		} else {
 846			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.attachments"), attachmentText))
 847		}
 848	} else {
 849		var b strings.Builder
 850		headerPrefix := "  "
 851		headerStyle := blurredStyle
 852		if m.focusIndex == focusAttachment {
 853			headerPrefix = "> "
 854			headerStyle = focusedStyle
 855		}
 856		b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths))))
 857		for i, p := range m.attachmentPaths {
 858			cursor := "    "
 859			style := blurredStyle
 860			if m.focusIndex == focusAttachment && i == m.attachmentCursor {
 861				cursor = "  > "
 862				style = focusedStyle
 863			}
 864			b.WriteString("\n")
 865			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p))))
 866		}
 867		attachmentField = b.String()
 868	}
 869
 870	encToggle := "[ ]"
 871	if m.encryptSMIME {
 872		encToggle = "[x]"
 873	}
 874	encField := blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.encrypt_smime"), encToggle))
 875	if m.focusIndex == focusEncryptSMIME {
 876		encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
 877	}
 878
 879	// Build To field with suggestions
 880	toFieldView := m.toInput.View()
 881	if m.toError != "" {
 882		toFieldView += "\n" + composerErrorStyle.Render(m.toError)
 883	}
 884	if m.showSuggestions && len(m.suggestions) > 0 {
 885		var suggestionsBuilder strings.Builder
 886		suggestionWidth := suggestionDisplayWidth(m.width)
 887		for i, s := range m.suggestions {
 888			display := suggestionDisplay(s, suggestionWidth)
 889			if i == m.selectedSuggestion {
 890				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
 891			} else {
 892				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
 893			}
 894		}
 895		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
 896	}
 897
 898	ccFieldView := m.ccInput.View()
 899	if m.ccError != "" {
 900		ccFieldView += "\n" + composerErrorStyle.Render(m.ccError)
 901	}
 902
 903	bccFieldView := m.bccInput.View()
 904	if m.bccError != "" {
 905		bccFieldView += "\n" + composerErrorStyle.Render(m.bccError)
 906	}
 907
 908	// Signature field label
 909	var signatureLabel string
 910	if m.focusIndex == focusSignature {
 911		signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
 912	} else {
 913		signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
 914	}
 915
 916	tip := ""
 917	switch m.focusIndex {
 918	case focusFrom:
 919		tip = "Select the account to send from."
 920	case focusTo:
 921		tip = "Enter recipient email addresses."
 922	case focusCc:
 923		tip = "Carbon copy recipients."
 924	case focusBcc:
 925		tip = "Blind carbon copy recipients."
 926	case focusSubject:
 927		tip = "The subject line of your email."
 928	case focusBody:
 929		tip = "The main content of your email. Markdown and HTML are supported."
 930	case focusSignature:
 931		tip = "Your email signature. This will be appended to the end of the email."
 932	case focusAttachment:
 933		tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete)
 934	case focusEncryptSMIME:
 935		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
 936	case focusSend:
 937		tip = "Press Enter to send the email."
 938	}
 939
 940	composerViewElements := []string{
 941		t("composer.title"),
 942		fromField,
 943		toFieldView,
 944		ccFieldView,
 945		bccFieldView,
 946		m.subjectInput.View(),
 947		m.bodyInput.View(),
 948		signatureLabel,
 949		m.signatureInput.View(),
 950		attachmentStyle.Render(attachmentField),
 951	}
 952	if len(m.attachmentPaths) > 0 {
 953		composerViewElements = append(composerViewElements, "")
 954	}
 955	composerViewElements = append(composerViewElements,
 956		smimeToggleStyle.Render(encField),
 957		button,
 958		"",
 959	)
 960
 961	if !m.hideTips && tip != "" {
 962		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
 963	}
 964
 965	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
 966	helpText := t("composer.help")
 967	for _, pk := range m.pluginKeyBindings {
 968		helpText += " • " + pk.Key + ": " + pk.Description
 969	}
 970	if m.pluginStatus != "" {
 971		helpText += " • " + m.pluginStatus
 972	}
 973	helpView := helpStyle.Render(helpText)
 974
 975	if m.height > 0 {
 976		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
 977		gap := m.height - currentHeight
 978		if gap >= 0 {
 979			mainContent += strings.Repeat("\n", gap+1)
 980		} else {
 981			mainContent += "\n"
 982		}
 983	} else {
 984		mainContent += "\n\n"
 985	}
 986
 987	composerView.WriteString(mainContent)
 988	composerView.WriteString(helpView)
 989
 990	// Plugin prompt overlay
 991	if m.showPluginPrompt {
 992		dialog := DialogBoxStyle.Render(
 993			lipgloss.JoinVertical(lipgloss.Left,
 994				m.pluginPromptPlaceholder,
 995				"",
 996				m.pluginPromptInput.View(),
 997				"",
 998				HelpStyle.Render("enter: submit • esc: cancel"),
 999			),
1000		)
1001		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1002	}
1003
1004	// Account picker overlay
1005	if m.showAccountPicker {
1006		var accountList strings.Builder
1007		accountList.WriteString("Select Account:\n\n")
1008		for i, acc := range m.accounts {
1009			display := acc.GetSendAsEmail()
1010			if acc.Name != "" {
1011				display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
1012			}
1013			if i == m.selectedAccountIdx {
1014				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
1015			} else {
1016				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
1017			}
1018			accountList.WriteString("\n")
1019		}
1020		accountList.WriteString("\n")
1021		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
1022
1023		dialog := DialogBoxStyle.Render(accountList.String())
1024		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1025	}
1026
1027	if m.confirmingExit {
1028		dialog := DialogBoxStyle.Render(
1029			lipgloss.JoinVertical(lipgloss.Center,
1030				t("composer.exit_confirm"),
1031				HelpStyle.Render("\n(y/n)"),
1032			),
1033		)
1034		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1035	}
1036
1037	if m.showNotice {
1038		dialog := DialogBoxStyle.Render(
1039			lipgloss.JoinVertical(lipgloss.Center,
1040				dangerStyle.Render(m.noticeText),
1041				HelpStyle.Render("\nenter/esc: close"),
1042			),
1043		)
1044		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1045	}
1046
1047	return tea.NewView(composerView.String())
1048}
1049
1050// SetAccounts sets the available accounts for sending.
1051func (m *Composer) SetAccounts(accounts []config.Account) {
1052	m.accounts = accounts
1053	if m.selectedAccountIdx >= len(accounts) {
1054		m.selectedAccountIdx = 0
1055	}
1056	m.updateSignature()
1057}
1058
1059// SetSelectedAccount sets the selected account by ID.
1060func (m *Composer) SetSelectedAccount(accountID string) {
1061	for i, acc := range m.accounts {
1062		if acc.ID == accountID {
1063			m.selectedAccountIdx = i
1064			m.updateSignature()
1065			return
1066		}
1067	}
1068}
1069
1070// GetSelectedAccountID returns the ID of the currently selected account.
1071func (m *Composer) GetSelectedAccountID() string {
1072	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
1073		return m.accounts[m.selectedAccountIdx].ID
1074	}
1075	return ""
1076}
1077
1078// GetDraftID returns the draft ID for this composer.
1079func (m *Composer) GetDraftID() string {
1080	return m.draftID
1081}
1082
1083// SetDraftID sets the draft ID (for loading existing drafts).
1084func (m *Composer) SetDraftID(id string) {
1085	m.draftID = id
1086}
1087
1088// GetTo returns the current To field value.
1089func (m *Composer) GetTo() string {
1090	return m.toInput.Value()
1091}
1092
1093// SetTo updates the To field with new content.
1094func (m *Composer) SetTo(to string) {
1095	m.toInput.SetValue(to)
1096}
1097
1098// GetCc returns the current Cc field value.
1099func (m *Composer) GetCc() string {
1100	return m.ccInput.Value()
1101}
1102
1103// SetCc updates the Cc field with new content.
1104func (m *Composer) SetCc(cc string) {
1105	m.ccInput.SetValue(cc)
1106}
1107
1108// GetBcc returns the current Bcc field value.
1109func (m *Composer) GetBcc() string {
1110	return m.bccInput.Value()
1111}
1112
1113// SetBcc updates the Bcc field with new content.
1114func (m *Composer) SetBcc(bcc string) {
1115	m.bccInput.SetValue(bcc)
1116}
1117
1118// GetSubject returns the current Subject field value.
1119func (m *Composer) GetSubject() string {
1120	return m.subjectInput.Value()
1121}
1122
1123// SetSubject updates the Subject field with new content.
1124func (m *Composer) SetSubject(subject string) {
1125	m.subjectInput.SetValue(subject)
1126}
1127
1128// GetBody returns the current Body field value.
1129func (m *Composer) GetBody() string {
1130	return m.bodyInput.Value()
1131}
1132
1133// SetBody updates the Body field with new content.
1134func (m *Composer) SetBody(body string) {
1135	m.bodyInput.SetValue(body)
1136}
1137
1138// GetAttachmentPaths returns the current attachment paths.
1139func (m *Composer) GetAttachmentPaths() []string {
1140	return m.attachmentPaths
1141}
1142
1143// GetSignature returns the current signature value.
1144func (m *Composer) GetSignature() string {
1145	return m.signatureInput.Value()
1146}
1147
1148// SetReplyContext sets the reply context for the draft.
1149func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
1150	m.inReplyTo = inReplyTo
1151	m.references = references
1152}
1153
1154// SetQuotedText sets the hidden quoted text that will be appended when sending.
1155func (m *Composer) SetQuotedText(text string) {
1156	m.quotedText = text
1157}
1158
1159// GetQuotedText returns the hidden quoted text.
1160func (m *Composer) GetQuotedText() string {
1161	return m.quotedText
1162}
1163
1164// GetInReplyTo returns the In-Reply-To header value.
1165func (m *Composer) GetInReplyTo() string {
1166	return m.inReplyTo
1167}
1168
1169// GetReferences returns the References header values.
1170func (m *Composer) GetReferences() []string {
1171	return m.references
1172}
1173
1174// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
1175func (m *Composer) SetPluginStatus(status string) {
1176	m.pluginStatus = status
1177}
1178
1179// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1180func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1181	m.pluginKeyBindings = bindings
1182}
1183
1184// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
1185func (m *Composer) ShowPluginPrompt(placeholder string) {
1186	m.pluginPromptPlaceholder = placeholder
1187	m.pluginPromptInput = textinput.New()
1188	m.pluginPromptInput.Placeholder = placeholder
1189	m.pluginPromptInput.Prompt = "> "
1190	m.pluginPromptInput.CharLimit = 256
1191	m.pluginPromptInput.Focus()
1192	m.showPluginPrompt = true
1193}
1194
1195// HidePluginPrompt deactivates the plugin prompt overlay.
1196func (m *Composer) HidePluginPrompt() {
1197	m.showPluginPrompt = false
1198}
1199
1200// ToDraft converts the composer state to a Draft for saving.
1201func (m *Composer) ToDraft() config.Draft {
1202	return config.Draft{
1203		ID:              m.draftID,
1204		To:              m.toInput.Value(),
1205		Cc:              m.ccInput.Value(),
1206		Bcc:             m.bccInput.Value(),
1207		Subject:         m.subjectInput.Value(),
1208		Body:            m.bodyInput.Value(),
1209		AttachmentPaths: m.attachmentPaths,
1210		AccountID:       m.GetSelectedAccountID(),
1211		FromOverride:    m.fromInput.Value(),
1212		InReplyTo:       m.inReplyTo,
1213		References:      m.references,
1214		QuotedText:      m.quotedText,
1215	}
1216}
1217
1218// NewComposerFromDraft creates a composer from an existing draft.
1219func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
1220	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
1221	m.ccInput.SetValue(draft.Cc)
1222	m.bccInput.SetValue(draft.Bcc)
1223	m.draftID = draft.ID
1224	m.attachmentPaths = draft.AttachmentPaths
1225	m.attachmentNames = make(map[string]string, len(m.attachmentPaths))
1226	for _, path := range m.attachmentPaths {
1227		m.attachmentNames[path] = formatAttachmentName(path)
1228	}
1229	m.clampAttachmentCursor()
1230	if m.isCatchAllAccount() && draft.FromOverride != "" {
1231		m.fromInput.SetValue(draft.FromOverride)
1232	}
1233	m.inReplyTo = draft.InReplyTo
1234	m.references = draft.References
1235	m.quotedText = draft.QuotedText
1236	return m
1237}