composer.go

   1package tui
   2
   3import (
   4	"fmt"
   5	"net/mail"
   6	"os"
   7	"path/filepath"
   8	"strings"
   9	"time"
  10	"unicode"
  11
  12	"charm.land/bubbles/v2/textarea"
  13	"charm.land/bubbles/v2/textinput"
  14	tea "charm.land/bubbletea/v2"
  15	"charm.land/lipgloss/v2"
  16	"github.com/floatpane/matcha/config"
  17	"github.com/floatpane/matcha/spellcheck"
  18	"github.com/google/uuid"
  19)
  20
  21// spellcheckReadyMsg is delivered when the background spellcheck loader
  22// finishes (either downloading the default dictionary or loading an
  23// already-installed one).
  24type spellcheckReadyMsg struct {
  25	checker *spellcheck.Checker
  26}
  27
  28var (
  29	suggestionStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  30	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  31	suggestionBoxStyle      = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
  32)
  33
  34// Styles for the UI
  35var (
  36	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
  37	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  38	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  39	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  40	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
  41	smimeToggleStyle    = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
  42	composerErrorStyle  = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("196"))
  43)
  44
  45const (
  46	focusFrom = iota
  47	focusTo
  48	focusCc
  49	focusBcc
  50	focusSubject
  51	focusBody
  52	focusSignature
  53	focusAttachment
  54	focusEncryptSMIME
  55	focusSend
  56)
  57
  58type hideComposerNoticeMsg struct{}
  59
  60// Composer model holds the state of the email composition UI.
  61type Composer struct {
  62	focusIndex       int
  63	toInput          textinput.Model
  64	ccInput          textinput.Model
  65	bccInput         textinput.Model
  66	fromError        string
  67	toError          string
  68	ccError          string
  69	bccError         string
  70	subjectInput     textinput.Model
  71	bodyInput        textarea.Model
  72	signatureInput   textarea.Model
  73	attachmentPaths  []string
  74	attachmentNames  map[string]string
  75	attachmentCursor int
  76	encryptSMIME     bool
  77	width            int
  78	height           int
  79	confirmingExit   bool
  80	showNotice       bool
  81	noticeText       string
  82	hideTips         bool
  83
  84	// Multi-account support
  85	accounts           []config.Account
  86	selectedAccountIdx int
  87	showAccountPicker  bool
  88	fromInput          textinput.Model // editable From when account is catch-all
  89
  90	// Contact suggestions
  91	suggestions        []config.Contact
  92	selectedSuggestion int
  93	showSuggestions    bool
  94	lastToValue        string
  95
  96	// Draft persistence
  97	draftID string
  98
  99	// Reply context
 100	inReplyTo  string
 101	references []string
 102
 103	// Hidden quoted text (appended to body when sending, but not shown in editor)
 104	quotedText string
 105
 106	// Plugin status text shown in the help bar
 107	pluginStatus      string
 108	pluginKeyBindings []PluginKeyBinding
 109
 110	// Plugin prompt overlay
 111	showPluginPrompt        bool
 112	pluginPromptInput       textinput.Model
 113	pluginPromptPlaceholder string
 114
 115	// Spellcheck (loaded asynchronously; nil until ready).
 116	spellChecker            *spellcheck.Checker
 117	spellSuggestions        []string
 118	spellSelected           int
 119	spellShow               bool
 120	spellWordOnLine         int    // index of the logical line containing the word
 121	spellWordLineStart      int    // byte offset of the word within its logical line
 122	spellWordLineEnd        int    // byte offset of the word's end within its logical line
 123	spellWord               string // the misspelled word (as currently in body)
 124	spellLastBody           string // last body value we computed suggestions for
 125	spellLastCursorRow      int
 126	spellLastCursorCol      int
 127	disableSpellcheck       bool
 128	disableSpellSuggestions bool
 129}
 130
 131// NewComposer initializes a new composer model.
 132func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 133	m := &Composer{
 134		draftID:         uuid.New().String(),
 135		hideTips:        hideTips,
 136		attachmentNames: make(map[string]string),
 137	}
 138
 139	tiStyles := ThemedTextInputStyles()
 140	taStyles := ThemedTextAreaStyles()
 141
 142	m.toInput = textinput.New()
 143	m.toInput.Placeholder = t("composer.to_placeholder")
 144	m.toInput.SetValue(to)
 145	m.toInput.Prompt = "> "
 146	m.toInput.CharLimit = 256
 147	m.toInput.SetStyles(tiStyles)
 148
 149	m.ccInput = textinput.New()
 150	m.ccInput.Placeholder = t("composer.cc_placeholder")
 151	m.ccInput.Prompt = "> "
 152	m.ccInput.CharLimit = 256
 153	m.ccInput.SetStyles(tiStyles)
 154
 155	m.bccInput = textinput.New()
 156	m.bccInput.Placeholder = t("composer.bcc_placeholder")
 157	m.bccInput.Prompt = "> "
 158	m.bccInput.CharLimit = 256
 159	m.bccInput.SetStyles(tiStyles)
 160
 161	m.subjectInput = textinput.New()
 162	m.subjectInput.Placeholder = t("composer.subject_placeholder")
 163	m.subjectInput.SetValue(subject)
 164	m.subjectInput.Prompt = "> "
 165	m.subjectInput.CharLimit = 256
 166	m.subjectInput.SetStyles(tiStyles)
 167
 168	m.bodyInput = textarea.New()
 169	m.bodyInput.Placeholder = t("composer.body_placeholder")
 170	m.bodyInput.SetValue(body)
 171	m.bodyInput.Prompt = "> "
 172	m.bodyInput.SetHeight(10)
 173	m.bodyInput.SetStyles(taStyles)
 174
 175	m.signatureInput = textarea.New()
 176	m.signatureInput.Placeholder = t("composer.signature_placeholder")
 177	m.signatureInput.Prompt = "> "
 178	m.signatureInput.SetHeight(3)
 179	m.signatureInput.SetStyles(taStyles)
 180	m.updateSignature()
 181
 182	m.fromInput = textinput.New()
 183	m.fromInput.Placeholder = t("composer.from_placeholder")
 184	m.fromInput.Prompt = "> "
 185	m.fromInput.CharLimit = 256
 186	m.fromInput.SetStyles(tiStyles)
 187
 188	// Start focus on To field (From is selectable but not a text input)
 189	m.focusIndex = focusTo
 190	m.toInput.Focus()
 191
 192	return m
 193}
 194
 195func normalizeEmailList(value string) (string, bool) {
 196	value = strings.TrimSpace(value)
 197	if value == "" {
 198		return "", true
 199	}
 200
 201	parts := strings.Split(value, ",")
 202	addresses := make([]string, 0, len(parts))
 203	for _, part := range parts {
 204		part = strings.TrimSpace(part)
 205		if part == "" {
 206			continue
 207		}
 208		addr, err := mail.ParseAddress(part)
 209		if err != nil || addr.Address == "" {
 210			return value, false
 211		}
 212		addresses = append(addresses, addr.Address)
 213	}
 214	if len(addresses) == 0 {
 215		return "", true
 216	}
 217	return strings.Join(addresses, ", "), true
 218}
 219
 220func (m *Composer) hasAnyRecipient() bool {
 221	return strings.TrimSpace(m.toInput.Value()) != "" ||
 222		strings.TrimSpace(m.ccInput.Value()) != "" ||
 223		strings.TrimSpace(m.bccInput.Value()) != ""
 224}
 225
 226func (m *Composer) showComposerNotice(message string) tea.Cmd {
 227	m.noticeText = message
 228	m.showNotice = true
 229	return tea.Tick(5*time.Second, func(time.Time) tea.Msg {
 230		return hideComposerNoticeMsg{}
 231	})
 232}
 233
 234func (m *Composer) hideComposerNotice() {
 235	m.showNotice = false
 236	m.noticeText = ""
 237}
 238
 239func (m *Composer) validateFromField() bool { //nolint:unparam
 240	if !m.isCatchAllAccount() {
 241		m.fromError = ""
 242		return true
 243	}
 244	value := strings.TrimSpace(m.fromInput.Value())
 245	addr, err := mail.ParseAddress(value)
 246	if value == "" || err != nil || addr.Address == "" {
 247		m.fromError = t("composer.invalid_email")
 248		return false
 249	}
 250	m.fromError = ""
 251	return true
 252}
 253
 254func (m *Composer) validateEmailField(focus int) bool { //nolint:unparam
 255	var input *textinput.Model
 256	var setError func(string)
 257	switch focus {
 258	case focusTo:
 259		input = &m.toInput
 260		setError = func(err string) { m.toError = err }
 261	case focusCc:
 262		input = &m.ccInput
 263		setError = func(err string) { m.ccError = err }
 264	case focusBcc:
 265		input = &m.bccInput
 266		setError = func(err string) { m.bccError = err }
 267	default:
 268		return true
 269	}
 270
 271	normalized, ok := normalizeEmailList(input.Value())
 272	if !ok {
 273		setError(t("composer.invalid_email"))
 274		return false
 275	}
 276	input.SetValue(normalized)
 277	setError("")
 278	return true
 279}
 280
 281func (m *Composer) canSendEmail() bool {
 282	m.validateFromField()
 283	m.validateEmailField(focusTo)
 284	m.validateEmailField(focusCc)
 285	m.validateEmailField(focusBcc)
 286	return m.fromError == "" && m.toError == "" && m.ccError == "" && m.bccError == ""
 287}
 288
 289// updateSignature updates the signature input based on the current selected account.
 290func (m *Composer) updateSignature() {
 291	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 292		acc := &m.accounts[m.selectedAccountIdx]
 293		if sig, err := config.LoadSignatureForAccount(acc); err == nil && sig != "" {
 294			m.signatureInput.SetValue(sig)
 295		} else if sig, err := config.LoadSignature(); err == nil && sig != "" {
 296			m.signatureInput.SetValue(sig)
 297		} else {
 298			m.signatureInput.SetValue("")
 299		}
 300		// Seed the editable From address for catch-all accounts.
 301		m.fromInput.SetValue(acc.FormatFromHeader())
 302		return
 303	}
 304
 305	if sig, err := config.LoadSignature(); err == nil && sig != "" {
 306		m.signatureInput.SetValue(sig)
 307	} else {
 308		m.signatureInput.SetValue("")
 309	}
 310}
 311
 312// NewComposerWithAccounts initializes a composer with multiple account support.
 313func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
 314	m := NewComposer("", to, subject, body, hideTips)
 315	m.accounts = accounts
 316
 317	// Find the selected account index
 318	for i, acc := range accounts {
 319		if acc.ID == selectedAccountID {
 320			m.selectedAccountIdx = i
 321			break
 322		}
 323	}
 324	m.updateSignature()
 325
 326	return m
 327}
 328
 329// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
 330func (m *Composer) ResetConfirmation() {
 331	m.confirmingExit = false
 332}
 333
 334// SetFromOverride pre-fills the editable From field (used for catch-all replies).
 335func (m *Composer) SetFromOverride(addr string) {
 336	m.fromInput.SetValue(addr)
 337}
 338
 339// SetSpellcheckOptions toggles spellcheck features for this composer. Pass
 340// disableCheck=true to skip dictionary download/highlighting entirely;
 341// disableSuggestions=true keeps inline underlines but suppresses the popup.
 342func (m *Composer) SetSpellcheckOptions(disableCheck, disableSuggestions bool) {
 343	m.disableSpellcheck = disableCheck
 344	m.disableSpellSuggestions = disableSuggestions
 345	if disableCheck {
 346		m.spellChecker = nil
 347		m.spellShow = false
 348		m.spellSuggestions = nil
 349	}
 350}
 351
 352func (m *Composer) Init() tea.Cmd {
 353	cmds := []tea.Cmd{textinput.Blink}
 354	if !m.disableSpellcheck {
 355		cmds = append(cmds, loadSpellcheckCmd())
 356	}
 357	return tea.Batch(cmds...)
 358}
 359
 360// loadSpellcheckCmd ensures the default dictionary is downloaded and
 361// loaded into a new Checker. Network errors are swallowed: spellcheck is a
 362// non-essential overlay, so the composer continues to work normally.
 363func loadSpellcheckCmd() tea.Cmd {
 364	return func() tea.Msg {
 365		lang, err := spellcheck.EnsureDefault()
 366		if err != nil {
 367			return spellcheckReadyMsg{checker: nil}
 368		}
 369		c := spellcheck.NewChecker()
 370		if err := c.LoadLang(lang); err != nil {
 371			return spellcheckReadyMsg{checker: nil}
 372		}
 373		return spellcheckReadyMsg{checker: c}
 374	}
 375}
 376
 377// updateSpellSuggestions inspects the body cursor position and refreshes
 378// the suggestion popup. It only fires when the cursor sits at the end of
 379// a misspelled word.
 380func (m *Composer) updateSpellSuggestions() {
 381	m.spellShow = false
 382	m.spellSuggestions = nil
 383	m.spellWord = ""
 384
 385	if m.disableSpellcheck || m.disableSpellSuggestions {
 386		return
 387	}
 388	if m.spellChecker == nil || !m.spellChecker.Loaded() {
 389		return
 390	}
 391	if m.focusIndex != focusBody {
 392		return
 393	}
 394
 395	value := m.bodyInput.Value()
 396	row := m.bodyInput.Line()
 397	col := m.bodyInput.Column()
 398	lines := strings.Split(value, "\n")
 399	if row < 0 || row >= len(lines) {
 400		return
 401	}
 402	line := lines[row]
 403	lineRunes := []rune(line)
 404	if col > len(lineRunes) {
 405		col = len(lineRunes)
 406	}
 407
 408	// Walk back from cursor while we have letters or internal connectors.
 409	end := col
 410	start := col
 411	for start > 0 {
 412		r := lineRunes[start-1]
 413		if isWordContinuation(r) {
 414			start--
 415			continue
 416		}
 417		break
 418	}
 419	// Trim leading connectors so the word starts on a letter.
 420	for start < end && !isLetter(lineRunes[start]) {
 421		start++
 422	}
 423	// Trim trailing connectors so we don't suggest replacements while the
 424	// user is still mid-apostrophe.
 425	for end > start && !isLetter(lineRunes[end-1]) {
 426		end--
 427	}
 428	if end-start < 2 {
 429		return
 430	}
 431
 432	word := string(lineRunes[start:end])
 433	if !spellcheck.IsCheckable(word) {
 434		return
 435	}
 436	if m.spellChecker.Check(word) {
 437		return
 438	}
 439
 440	suggestions := m.spellChecker.Suggest(word, 5)
 441	if len(suggestions) == 0 {
 442		return
 443	}
 444
 445	m.spellSuggestions = suggestions
 446	m.spellSelected = 0
 447	m.spellShow = true
 448	m.spellWord = word
 449
 450	// Byte offsets within the current line, needed by the accept handler.
 451	m.spellWordLineStart = len(string(lineRunes[:start]))
 452	m.spellWordLineEnd = len(string(lineRunes[:end]))
 453	m.spellWordOnLine = row
 454
 455	// Cache cursor position so a no-op key (e.g. arrow without movement)
 456	// doesn't redundantly recompute suggestions.
 457	m.spellLastBody = value
 458	m.spellLastCursorRow = row
 459	m.spellLastCursorCol = col
 460}
 461
 462// acceptSpellSuggestion replaces the misspelled word currently under the
 463// cursor with the selected suggestion. It works by sending backspace key
 464// events to the textarea (so the textarea's own bookkeeping stays in
 465// sync) and then inserting the replacement text.
 466func (m *Composer) acceptSpellSuggestion() {
 467	if !m.spellShow || len(m.spellSuggestions) == 0 {
 468		return
 469	}
 470	if m.spellSelected < 0 || m.spellSelected >= len(m.spellSuggestions) {
 471		return
 472	}
 473	suggestion := m.spellSuggestions[m.spellSelected]
 474
 475	// Only replace when the cursor is still at the end of the word we
 476	// recorded — otherwise the user moved and the popup is stale.
 477	row := m.bodyInput.Line()
 478	col := m.bodyInput.Column()
 479	lines := strings.Split(m.bodyInput.Value(), "\n")
 480	if row != m.spellWordOnLine || row >= len(lines) {
 481		m.spellShow = false
 482		m.spellSuggestions = nil
 483		return
 484	}
 485	endRunes := len([]rune(lines[row][:m.spellWordLineEnd]))
 486	if col != endRunes {
 487		m.spellShow = false
 488		m.spellSuggestions = nil
 489		return
 490	}
 491
 492	wordRuneLen := len([]rune(m.spellWord))
 493	for i := 0; i < wordRuneLen; i++ {
 494		m.bodyInput, _ = m.bodyInput.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
 495	}
 496	m.bodyInput.InsertString(suggestion)
 497
 498	m.spellShow = false
 499	m.spellSuggestions = nil
 500	m.spellWord = ""
 501}
 502
 503func isWordContinuation(r rune) bool {
 504	return isLetter(r) || r == '\'' || r == '’' || r == '-'
 505}
 506
 507func isLetter(r rune) bool {
 508	if r < 0x80 {
 509		return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
 510	}
 511	return unicode.IsLetter(r)
 512}
 513
 514func (m *Composer) getFromAddress() string {
 515	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 516		return m.accounts[m.selectedAccountIdx].FormatFromHeader()
 517	}
 518	return ""
 519}
 520
 521func (m *Composer) isCatchAllAccount() bool {
 522	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 523		return m.accounts[m.selectedAccountIdx].CatchAll
 524	}
 525	return false
 526}
 527
 528func (m *Composer) getSelectedAccount() *config.Account {
 529	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 530		return &m.accounts[m.selectedAccountIdx]
 531	}
 532	return nil
 533}
 534
 535func formatAttachmentName(path string) string {
 536	name := filepath.Base(path)
 537	info, err := os.Stat(path)
 538	if err != nil || info.IsDir() {
 539		return name
 540	}
 541	return fmt.Sprintf("%s (%s)", name, tfs(info.Size()))
 542}
 543
 544func (m *Composer) attachmentDisplayName(path string) string {
 545	if name, ok := m.attachmentNames[path]; ok {
 546		return name
 547	}
 548	return filepath.Base(path)
 549}
 550
 551func (m *Composer) clampAttachmentCursor() {
 552	if len(m.attachmentPaths) == 0 {
 553		m.attachmentCursor = 0
 554		return
 555	}
 556	if m.attachmentCursor < 0 {
 557		m.attachmentCursor = 0
 558	}
 559	if m.attachmentCursor >= len(m.attachmentPaths) {
 560		m.attachmentCursor = len(m.attachmentPaths) - 1
 561	}
 562}
 563
 564func (m *Composer) removeSelectedAttachment() {
 565	if len(m.attachmentPaths) == 0 {
 566		return
 567	}
 568
 569	m.clampAttachmentCursor()
 570	idx := m.attachmentCursor
 571	delete(m.attachmentNames, m.attachmentPaths[idx])
 572	m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...)
 573	m.clampAttachmentCursor()
 574}
 575
 576func suggestionDisplay(s config.Contact, suggestionWidth int) string {
 577	display := s.Email
 578	if len(s.Addresses) > 0 {
 579		display = fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Addresses, ", "))
 580		return truncateSuggestionDisplay(display, suggestionWidth)
 581	} else if s.Name != "" && s.Name != s.Email {
 582		display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
 583	}
 584	return display
 585}
 586
 587func suggestionDisplayWidth(width int) int {
 588	if width > 12 {
 589		return width - 6
 590	}
 591	return 40
 592}
 593
 594func truncateSuggestionDisplay(s string, maxLen int) string {
 595	runes := []rune(s)
 596	if len(runes) <= maxLen {
 597		return s
 598	}
 599	if maxLen <= 0 {
 600		return ""
 601	}
 602	if maxLen <= 3 {
 603		return string(runes[:maxLen])
 604	}
 605	return string(runes[:maxLen-3]) + "..."
 606}
 607
 608func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 609	var cmds []tea.Cmd
 610	var cmd tea.Cmd
 611
 612	switch msg := msg.(type) {
 613	case tea.WindowSizeMsg:
 614		m.width = msg.Width
 615		m.height = msg.Height
 616		inputWidth := msg.Width - 6
 617		m.toInput.SetWidth(inputWidth)
 618		m.ccInput.SetWidth(inputWidth)
 619		m.bccInput.SetWidth(inputWidth)
 620		m.subjectInput.SetWidth(inputWidth)
 621		m.bodyInput.SetWidth(inputWidth)
 622		m.signatureInput.SetWidth(inputWidth)
 623		if msg.Height > 0 {
 624			// Fixed rows: title, from, to, cc, bcc, subject, sig label,
 625			// attachment, smime, button, blank, tip, help = 13
 626			const fixedRows = 13
 627			available := msg.Height - fixedRows
 628			if available < 6 {
 629				available = 6
 630			}
 631			bodyHeight := (available * 55) / 100
 632			sigHeight := (available * 15) / 100
 633			if bodyHeight < 3 {
 634				bodyHeight = 3
 635			}
 636			if sigHeight < 2 {
 637				sigHeight = 2
 638			}
 639			m.bodyInput.SetHeight(bodyHeight)
 640			m.signatureInput.SetHeight(sigHeight)
 641		}
 642
 643	case hideComposerNoticeMsg:
 644		m.hideComposerNotice()
 645		return m, nil
 646
 647	case spellcheckReadyMsg:
 648		if msg.checker != nil {
 649			m.spellChecker = msg.checker
 650			m.updateSpellSuggestions()
 651		}
 652		return m, nil
 653
 654	case FileSelectedMsg:
 655		// Avoid duplicates and add all selected paths
 656		for _, newPath := range msg.Paths {
 657			exists := false
 658			for _, p := range m.attachmentPaths {
 659				if p == newPath {
 660					exists = true
 661					break
 662				}
 663			}
 664			if !exists {
 665				m.attachmentPaths = append(m.attachmentPaths, newPath)
 666				m.attachmentNames[newPath] = formatAttachmentName(newPath)
 667			}
 668		}
 669		m.clampAttachmentCursor()
 670		return m, nil
 671
 672	case tea.KeyPressMsg:
 673		// Handle contact suggestions mode
 674		if m.showSuggestions && len(m.suggestions) > 0 {
 675			switch msg.String() {
 676			case "up", "ctrl+p":
 677				if m.selectedSuggestion > 0 {
 678					m.selectedSuggestion--
 679				}
 680				return m, nil
 681			case keyDown, "ctrl+n":
 682				if m.selectedSuggestion < len(m.suggestions)-1 {
 683					m.selectedSuggestion++
 684				}
 685				return m, nil
 686			case "tab", keyEnter:
 687				// Select the suggestion
 688				selected := m.suggestions[m.selectedSuggestion]
 689
 690				var newEmail string
 691				switch {
 692				case len(selected.Addresses) > 0:
 693					// Mailing list: emit just the addresses to maintain valid email formatting
 694					newEmail = strings.Join(selected.Addresses, ", ")
 695				case selected.Name != "" && selected.Name != selected.Email:
 696					newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
 697				default:
 698					newEmail = selected.Email
 699				}
 700
 701				parts := strings.Split(m.toInput.Value(), ",")
 702				if len(parts) > 0 {
 703					if len(parts) == 1 {
 704						parts[0] = newEmail
 705					} else {
 706						parts[len(parts)-1] = " " + newEmail
 707					}
 708				} else {
 709					parts = []string{newEmail}
 710				}
 711
 712				finalValue := strings.Join(parts, ",")
 713				if !strings.HasSuffix(finalValue, ", ") {
 714					finalValue += ", "
 715				}
 716
 717				m.toInput.SetValue(finalValue)
 718				m.toInput.SetCursor(len(finalValue))
 719				m.toError = ""
 720				m.lastToValue = m.toInput.Value()
 721				m.showSuggestions = false
 722				m.suggestions = nil
 723				return m, nil
 724			case "esc":
 725				m.showSuggestions = false
 726				m.suggestions = nil
 727				return m, nil
 728			}
 729			// For prev-field key, close suggestions and let it fall through to normal handling
 730			if msg.String() == config.Keybinds.Composer.PrevField {
 731				m.showSuggestions = false
 732				m.suggestions = nil
 733			}
 734		}
 735
 736		// Handle plugin prompt overlay
 737		if m.showPluginPrompt {
 738			switch msg.String() {
 739			case keyEnter:
 740				value := m.pluginPromptInput.Value()
 741				m.showPluginPrompt = false
 742				return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
 743			case "esc":
 744				m.showPluginPrompt = false
 745				return m, func() tea.Msg { return PluginPromptCancelMsg{} }
 746			default:
 747				m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
 748				return m, cmd
 749			}
 750		}
 751
 752		// Handle account picker mode
 753		if m.showAccountPicker {
 754			switch msg.String() {
 755			case "up", "k":
 756				if m.selectedAccountIdx > 0 {
 757					m.selectedAccountIdx--
 758					m.updateSignature()
 759				}
 760			case keyDown, "j":
 761				if m.selectedAccountIdx < len(m.accounts)-1 {
 762					m.selectedAccountIdx++
 763					m.updateSignature()
 764				}
 765			case keyEnter:
 766				m.showAccountPicker = false
 767			case "esc":
 768				m.showAccountPicker = false
 769			}
 770			return m, nil
 771		}
 772
 773		if m.confirmingExit {
 774			switch msg.String() {
 775			case "y", "Y":
 776				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
 777			case "n", "N", "esc":
 778				m.confirmingExit = false
 779				return m, nil
 780			default:
 781				return m, nil
 782			}
 783		}
 784
 785		if m.showNotice {
 786			switch msg.String() {
 787			case keyEnter, "esc", " ":
 788				m.hideComposerNotice()
 789			}
 790			return m, nil
 791		}
 792
 793		// Spellcheck suggestion popup (only while body is focused).
 794		if m.focusIndex == focusBody && m.spellShow && len(m.spellSuggestions) > 0 {
 795			sk := config.Keybinds.Composer
 796			switch msg.String() {
 797			case sk.SpellPrev:
 798				if m.spellSelected > 0 {
 799					m.spellSelected--
 800				}
 801				return m, nil
 802			case sk.SpellNext:
 803				if m.spellSelected < len(m.spellSuggestions)-1 {
 804					m.spellSelected++
 805				}
 806				return m, nil
 807			case sk.SpellAccept:
 808				m.acceptSpellSuggestion()
 809				return m, nil
 810			case sk.SpellDismiss:
 811				m.spellShow = false
 812				m.spellSuggestions = nil
 813				return m, nil
 814			}
 815		}
 816
 817		kb := config.Keybinds
 818		attachmentPathSize := len(m.attachmentPaths)
 819		if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
 820			switch msg.String() {
 821			case "up", kb.Global.NavUp:
 822				m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize
 823				return m, nil
 824			case keyDown, kb.Global.NavDown:
 825				m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize
 826				return m, nil
 827			}
 828		}
 829
 830		switch msg.String() {
 831		case kb.Global.Quit:
 832			return m, tea.Quit
 833		case kb.Composer.ExternalEditor:
 834			return m, func() tea.Msg { return OpenEditorMsg{} }
 835		case kb.Global.Cancel:
 836			m.confirmingExit = true
 837			return m, nil
 838
 839		case kb.Composer.NextField, kb.Composer.PrevField:
 840			previousFocus := m.focusIndex
 841			if msg.String() == kb.Composer.PrevField {
 842				m.focusIndex--
 843			} else {
 844				m.focusIndex++
 845			}
 846
 847			maxFocus := focusSend
 848			minFocus := focusFrom
 849			// Skip From field if only one non-catch-all account (nothing to switch or edit)
 850			if len(m.accounts) <= 1 && !m.isCatchAllAccount() {
 851				minFocus = focusTo
 852			}
 853
 854			if m.focusIndex > maxFocus {
 855				m.focusIndex = minFocus
 856			} else if m.focusIndex < minFocus {
 857				m.focusIndex = maxFocus
 858			}
 859
 860			if previousFocus == focusFrom {
 861				m.validateFromField()
 862			} else if previousFocus != m.focusIndex {
 863				m.validateEmailField(previousFocus)
 864			}
 865
 866			m.fromInput.Blur()
 867			m.toInput.Blur()
 868			m.ccInput.Blur()
 869			m.bccInput.Blur()
 870			m.subjectInput.Blur()
 871			m.bodyInput.Blur()
 872			m.signatureInput.Blur()
 873			m.spellShow = false
 874			m.spellSuggestions = nil
 875
 876			switch m.focusIndex {
 877			case focusFrom:
 878				if m.isCatchAllAccount() {
 879					cmds = append(cmds, m.fromInput.Focus())
 880				}
 881			case focusTo:
 882				cmds = append(cmds, m.toInput.Focus())
 883			case focusCc:
 884				cmds = append(cmds, m.ccInput.Focus())
 885			case focusBcc:
 886				cmds = append(cmds, m.bccInput.Focus())
 887			case focusSubject:
 888				cmds = append(cmds, m.subjectInput.Focus())
 889			case focusBody:
 890				cmds = append(cmds, m.bodyInput.Focus())
 891			case focusSignature:
 892				cmds = append(cmds, m.signatureInput.Focus())
 893			}
 894			return m, tea.Batch(cmds...)
 895
 896		case kb.Composer.Delete:
 897			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
 898				m.removeSelectedAttachment()
 899				return m, nil
 900			}
 901
 902		case keyEnter, " ":
 903			switch m.focusIndex {
 904			case focusFrom:
 905				if msg.String() == keyEnter && len(m.accounts) > 1 {
 906					m.showAccountPicker = true
 907					return m, nil
 908				}
 909				if m.isCatchAllAccount() && msg.String() == " " {
 910					break
 911				}
 912				return m, nil
 913			case focusAttachment:
 914				if msg.String() == keyEnter {
 915					return m, func() tea.Msg { return GoToFilePickerMsg{} }
 916				}
 917			case focusEncryptSMIME:
 918				if msg.String() == keyEnter || msg.String() == " " {
 919					m.encryptSMIME = !m.encryptSMIME
 920				}
 921				return m, nil
 922
 923			case focusSend:
 924				if msg.String() == keyEnter {
 925					if !m.canSendEmail() {
 926						return m, m.showComposerNotice(t("composer.invalid_email_fields"))
 927					}
 928					if !m.hasAnyRecipient() {
 929						return m, m.showComposerNotice(t("composer.recipient_required"))
 930					}
 931					acc := m.getSelectedAccount()
 932					accountID := ""
 933					if acc != nil {
 934						accountID = acc.ID
 935					}
 936					fromOverride := ""
 937					if m.isCatchAllAccount() {
 938						fromOverride = m.fromInput.Value()
 939					}
 940					return m, func() tea.Msg {
 941						return SendEmailMsg{
 942							To:              m.toInput.Value(),
 943							Cc:              m.ccInput.Value(),
 944							Bcc:             m.bccInput.Value(),
 945							Subject:         m.subjectInput.Value(),
 946							Body:            m.bodyInput.Value(),
 947							AttachmentPaths: m.attachmentPaths,
 948							AccountID:       accountID,
 949							FromOverride:    fromOverride,
 950							QuotedText:      m.quotedText,
 951							InReplyTo:       m.inReplyTo,
 952							References:      m.references,
 953							Signature:       m.signatureInput.Value(),
 954							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
 955							EncryptSMIME:    m.encryptSMIME,
 956							SignPGP:         acc != nil && acc.PGPSignByDefault,
 957						}
 958					}
 959				}
 960			}
 961		}
 962	}
 963
 964	switch m.focusIndex {
 965	case focusFrom:
 966		if m.isCatchAllAccount() {
 967			previousFromValue := m.fromInput.Value()
 968			m.fromInput, cmd = m.fromInput.Update(msg)
 969			cmds = append(cmds, cmd)
 970			if m.fromInput.Value() != previousFromValue {
 971				m.fromError = ""
 972			}
 973		}
 974	case focusTo:
 975		previousToValue := m.toInput.Value()
 976		m.toInput, cmd = m.toInput.Update(msg)
 977		cmds = append(cmds, cmd)
 978
 979		// Check if To field value changed and update suggestions
 980		currentValue := m.toInput.Value()
 981		if currentValue != m.lastToValue {
 982			if currentValue != previousToValue {
 983				m.toError = ""
 984			}
 985			m.lastToValue = currentValue
 986
 987			// Extract the last comma-separated part for searching
 988			parts := strings.Split(currentValue, ",")
 989			lastPart := strings.TrimSpace(parts[len(parts)-1])
 990
 991			if len(lastPart) >= 2 {
 992				m.suggestions = config.SearchContactsForAccount(lastPart, m.GetSelectedAccountID())
 993				m.showSuggestions = len(m.suggestions) > 0
 994				m.selectedSuggestion = 0
 995			} else {
 996				m.showSuggestions = false
 997				m.suggestions = nil
 998			}
 999		}
1000	case focusCc:
1001		previousCcValue := m.ccInput.Value()
1002		m.ccInput, cmd = m.ccInput.Update(msg)
1003		cmds = append(cmds, cmd)
1004		if m.ccInput.Value() != previousCcValue {
1005			m.ccError = ""
1006		}
1007	case focusBcc:
1008		previousBccValue := m.bccInput.Value()
1009		m.bccInput, cmd = m.bccInput.Update(msg)
1010		cmds = append(cmds, cmd)
1011		if m.bccInput.Value() != previousBccValue {
1012			m.bccError = ""
1013		}
1014	case focusSubject:
1015		m.subjectInput, cmd = m.subjectInput.Update(msg)
1016		cmds = append(cmds, cmd)
1017	case focusBody:
1018		prevBody := m.bodyInput.Value()
1019		prevRow := m.bodyInput.Line()
1020		prevCol := m.bodyInput.Column()
1021		m.bodyInput, cmd = m.bodyInput.Update(msg)
1022		cmds = append(cmds, cmd)
1023		// Only recompute suggestions when the body state actually changes.
1024		// Cursor-blink ticks otherwise reset spellSelected to 0 every blink.
1025		if m.bodyInput.Value() != prevBody ||
1026			m.bodyInput.Line() != prevRow ||
1027			m.bodyInput.Column() != prevCol {
1028			m.updateSpellSuggestions()
1029		}
1030	case focusSignature:
1031		m.signatureInput, cmd = m.signatureInput.Update(msg)
1032		cmds = append(cmds, cmd)
1033	}
1034
1035	return m, tea.Batch(cmds...)
1036}
1037
1038func (m *Composer) View() tea.View { //nolint:gocyclo
1039	var composerView strings.Builder
1040	var button string
1041	ck := config.Keybinds.Composer
1042
1043	if m.focusIndex == focusSend {
1044		button = focusedStyle.Render("[ " + t("composer.send") + " ]")
1045	} else {
1046		button = blurredStyle.Render("[ " + t("composer.send") + " ]")
1047	}
1048
1049	// From field with account selector
1050	fromAddr := m.getFromAddress()
1051	var fromField string
1052	if m.isCatchAllAccount() { //nolint:gocritic
1053		fromAddrView := m.fromInput.View()
1054		if len(m.accounts) > 1 {
1055			if m.focusIndex == focusFrom {
1056				fromField = focusedStyle.Render(fmt.Sprintf("> %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.enter_to_switch")+"]")
1057			} else {
1058				fromField = blurredStyle.Render(fmt.Sprintf("  %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.switchable")+"]")
1059			}
1060		} else {
1061			fromField = "  " + t("composer.from") + " " + fromAddrView
1062		}
1063		if m.fromError != "" {
1064			fromField += "\n" + composerErrorStyle.Render(m.fromError)
1065		}
1066	} else if len(m.accounts) > 1 {
1067		if m.focusIndex == focusFrom {
1068			fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
1069		} else {
1070			fromField = blurredStyle.Render(fmt.Sprintf("  %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
1071		}
1072	} else if fromAddr != "" {
1073		fromField = "  " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
1074	} else {
1075		fromField = blurredStyle.Render(fmt.Sprintf("  %s (%s)", t("composer.from"), t("composer.no_account")))
1076	}
1077
1078	var attachmentField string
1079	if len(m.attachmentPaths) == 0 {
1080		attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
1081		if m.focusIndex == focusAttachment {
1082			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
1083		} else {
1084			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.attachments"), attachmentText))
1085		}
1086	} else {
1087		var b strings.Builder
1088		headerPrefix := "  "
1089		headerStyle := blurredStyle
1090		if m.focusIndex == focusAttachment {
1091			headerPrefix = "> "
1092			headerStyle = focusedStyle
1093		}
1094		b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths))))
1095		for i, p := range m.attachmentPaths {
1096			cursor := "    "
1097			style := blurredStyle
1098			if m.focusIndex == focusAttachment && i == m.attachmentCursor {
1099				cursor = "  > "
1100				style = focusedStyle
1101			}
1102			b.WriteString("\n")
1103			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p))))
1104		}
1105		attachmentField = b.String()
1106	}
1107
1108	encToggle := "[ ]"
1109	if m.encryptSMIME {
1110		encToggle = "[x]"
1111	}
1112	encField := blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.encrypt_smime"), encToggle))
1113	if m.focusIndex == focusEncryptSMIME {
1114		encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
1115	}
1116
1117	// Build To field with suggestions
1118	toFieldView := m.toInput.View()
1119	if m.toError != "" {
1120		toFieldView += "\n" + composerErrorStyle.Render(m.toError)
1121	}
1122	if m.showSuggestions && len(m.suggestions) > 0 {
1123		var suggestionsBuilder strings.Builder
1124		suggestionWidth := suggestionDisplayWidth(m.width)
1125		for i, s := range m.suggestions {
1126			display := suggestionDisplay(s, suggestionWidth)
1127			if i == m.selectedSuggestion {
1128				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
1129			} else {
1130				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
1131			}
1132		}
1133		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
1134	}
1135
1136	ccFieldView := m.ccInput.View()
1137	if m.ccError != "" {
1138		ccFieldView += "\n" + composerErrorStyle.Render(m.ccError)
1139	}
1140
1141	bccFieldView := m.bccInput.View()
1142	if m.bccError != "" {
1143		bccFieldView += "\n" + composerErrorStyle.Render(m.bccError)
1144	}
1145
1146	// Signature field label
1147	var signatureLabel string
1148	if m.focusIndex == focusSignature {
1149		signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
1150	} else {
1151		signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
1152	}
1153
1154	tip := ""
1155	switch m.focusIndex {
1156	case focusFrom:
1157		tip = "Select the account to send from."
1158	case focusTo:
1159		tip = "Enter recipient email addresses."
1160	case focusCc:
1161		tip = "Carbon copy recipients."
1162	case focusBcc:
1163		tip = "Blind carbon copy recipients."
1164	case focusSubject:
1165		tip = "The subject line of your email."
1166	case focusBody:
1167		if m.spellShow && len(m.spellSuggestions) > 0 {
1168			sk := config.Keybinds.Composer
1169			tip = fmt.Sprintf("Spelling: %s accept • %s/%s navigate • %s dismiss",
1170				sk.SpellAccept, sk.SpellNext, sk.SpellPrev, sk.SpellDismiss)
1171		} else {
1172			tip = "The main content of your email. Markdown and HTML are supported."
1173		}
1174	case focusSignature:
1175		tip = "Your email signature. This will be appended to the end of the email."
1176	case focusAttachment:
1177		tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete)
1178	case focusEncryptSMIME:
1179		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
1180	case focusSend:
1181		tip = "Press Enter to send the email."
1182	}
1183
1184	bodyView := m.bodyInput.View()
1185	if !m.disableSpellcheck && m.spellChecker != nil && m.spellChecker.Loaded() {
1186		bodyView = spellcheck.Highlight(bodyView, m.spellChecker, -1)
1187	}
1188
1189	composerViewElements := []string{
1190		t("composer.title"),
1191		fromField,
1192		toFieldView,
1193		ccFieldView,
1194		bccFieldView,
1195		m.subjectInput.View(),
1196		bodyView,
1197		signatureLabel,
1198		m.signatureInput.View(),
1199		attachmentStyle.Render(attachmentField),
1200	}
1201	if len(m.attachmentPaths) > 0 {
1202		composerViewElements = append(composerViewElements, "")
1203	}
1204	composerViewElements = append(composerViewElements,
1205		smimeToggleStyle.Render(encField),
1206		button,
1207		"",
1208	)
1209
1210	if !m.hideTips && tip != "" {
1211		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
1212	}
1213
1214	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
1215	helpText := t("composer.help")
1216	for _, pk := range m.pluginKeyBindings {
1217		helpText += " • " + pk.Key + ": " + pk.Description
1218	}
1219	if m.pluginStatus != "" {
1220		helpText += " • " + m.pluginStatus
1221	}
1222	helpView := helpStyle.Render(helpText)
1223
1224	if m.height > 0 {
1225		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
1226		gap := m.height - currentHeight
1227		if gap >= 0 {
1228			mainContent += strings.Repeat("\n", gap+1)
1229		} else {
1230			mainContent += "\n"
1231		}
1232	} else {
1233		mainContent += "\n\n"
1234	}
1235
1236	composerView.WriteString(mainContent)
1237	composerView.WriteString(helpView)
1238
1239	// Plugin prompt overlay
1240	if m.showPluginPrompt {
1241		dialog := DialogBoxStyle.Render(
1242			lipgloss.JoinVertical(lipgloss.Left,
1243				m.pluginPromptPlaceholder,
1244				"",
1245				m.pluginPromptInput.View(),
1246				"",
1247				HelpStyle.Render("enter: submit • esc: cancel"),
1248			),
1249		)
1250		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1251	}
1252
1253	// Account picker overlay
1254	if m.showAccountPicker {
1255		var accountList strings.Builder
1256		accountList.WriteString("Select Account:\n\n")
1257		for i, acc := range m.accounts {
1258			display := acc.GetSendAsEmail()
1259			if acc.Name != "" {
1260				display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
1261			}
1262			if i == m.selectedAccountIdx {
1263				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
1264			} else {
1265				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
1266			}
1267			accountList.WriteString("\n")
1268		}
1269		accountList.WriteString("\n")
1270		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
1271
1272		dialog := DialogBoxStyle.Render(accountList.String())
1273		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1274	}
1275
1276	if m.confirmingExit {
1277		dialog := DialogBoxStyle.Render(
1278			lipgloss.JoinVertical(lipgloss.Center,
1279				t("composer.exit_confirm"),
1280				HelpStyle.Render("\n(y/n)"),
1281			),
1282		)
1283		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1284	}
1285
1286	if m.showNotice {
1287		dialog := DialogBoxStyle.Render(
1288			lipgloss.JoinVertical(lipgloss.Center,
1289				dangerStyle.Render(m.noticeText),
1290				HelpStyle.Render("\nenter/esc: close"),
1291			),
1292		)
1293		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1294	}
1295
1296	out := composerView.String()
1297	if m.spellShow && len(m.spellSuggestions) > 0 && m.focusIndex == focusBody {
1298		out = m.overlaySpellPopup(out, composerViewElements)
1299	}
1300	return tea.NewView(out)
1301}
1302
1303// overlaySpellPopup floats the suggestion box at the body cursor position
1304// in the rendered composer view. It returns the view unchanged when the
1305// cursor can't be located.
1306func (m *Composer) overlaySpellPopup(view string, elementsBeforeBody []string) string {
1307	// Body is the 7th element (index 6) of composerViewElements: title,
1308	// from, to, cc, bcc, subject, body, ...
1309	const bodyIdx = 6
1310	if bodyIdx > len(elementsBeforeBody) {
1311		return view
1312	}
1313	bodyStartRow := 0
1314	for i := 0; i < bodyIdx; i++ {
1315		bodyStartRow += lipgloss.Height(elementsBeforeBody[i])
1316	}
1317
1318	li := m.bodyInput.LineInfo()
1319	const promptWidth = 2 // "> "
1320	cursorRow := bodyStartRow + li.RowOffset
1321	cursorCol := li.CharOffset + promptWidth
1322
1323	popup := m.renderSpellPopupLines()
1324	if len(popup) == 0 {
1325		return view
1326	}
1327
1328	// Anchor below cursor. If popup would clip the bottom, raise it above
1329	// the cursor row instead.
1330	anchorRow := cursorRow + 1
1331	if m.height > 0 && anchorRow+len(popup) > m.height-1 && cursorRow-len(popup) >= 0 {
1332		anchorRow = cursorRow - len(popup)
1333	}
1334	anchorCol := cursorCol
1335	popupWidth := lipgloss.Width(popup[0])
1336	if m.width > 0 && anchorCol+popupWidth > m.width {
1337		anchorCol = max(0, m.width-popupWidth)
1338	}
1339
1340	return overlayBlock(view, popup, anchorRow, anchorCol)
1341}
1342
1343// renderSpellPopupLines builds the styled, bordered suggestion box and
1344// returns its rendered lines. Each row carries an "abc" badge to mirror
1345// the language-server look familiar from VSCode.
1346func (m *Composer) renderSpellPopupLines() []string {
1347	if !m.spellShow || len(m.spellSuggestions) == 0 {
1348		return nil
1349	}
1350	maxWidth := 0
1351	for _, s := range m.spellSuggestions {
1352		if w := len(s); w > maxWidth {
1353			maxWidth = w
1354		}
1355	}
1356	rowWidth := maxWidth + 6 // " abc " badge + word + trailing space
1357
1358	iconStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
1359	rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
1360	selStyle := lipgloss.NewStyle().Background(lipgloss.Color("24")).Foreground(lipgloss.Color("231"))
1361
1362	var rows []string
1363	for i, s := range m.spellSuggestions {
1364		text := " " + iconStyle.Render("abc") + " " + s
1365		pad := rowWidth - lipgloss.Width(text)
1366		if pad < 0 {
1367			pad = 0
1368		}
1369		text += strings.Repeat(" ", pad)
1370		if i == m.spellSelected {
1371			rows = append(rows, selStyle.Render(text))
1372		} else {
1373			rows = append(rows, rowStyle.Render(text))
1374		}
1375	}
1376	box := suggestionBoxStyle.Render(strings.Join(rows, "\n"))
1377	return strings.Split(box, "\n")
1378}
1379
1380// SetAccounts sets the available accounts for sending.
1381func (m *Composer) SetAccounts(accounts []config.Account) {
1382	m.accounts = accounts
1383	if m.selectedAccountIdx >= len(accounts) {
1384		m.selectedAccountIdx = 0
1385	}
1386	m.updateSignature()
1387}
1388
1389// SetSelectedAccount sets the selected account by ID.
1390func (m *Composer) SetSelectedAccount(accountID string) {
1391	for i, acc := range m.accounts {
1392		if acc.ID == accountID {
1393			m.selectedAccountIdx = i
1394			m.updateSignature()
1395			return
1396		}
1397	}
1398}
1399
1400// GetSelectedAccountID returns the ID of the currently selected account.
1401func (m *Composer) GetSelectedAccountID() string {
1402	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
1403		return m.accounts[m.selectedAccountIdx].ID
1404	}
1405	return ""
1406}
1407
1408// GetDraftID returns the draft ID for this composer.
1409func (m *Composer) GetDraftID() string {
1410	return m.draftID
1411}
1412
1413// SetDraftID sets the draft ID (for loading existing drafts).
1414func (m *Composer) SetDraftID(id string) {
1415	m.draftID = id
1416}
1417
1418// GetTo returns the current To field value.
1419func (m *Composer) GetTo() string {
1420	return m.toInput.Value()
1421}
1422
1423// SetTo updates the To field with new content.
1424func (m *Composer) SetTo(to string) {
1425	m.toInput.SetValue(to)
1426}
1427
1428// GetCc returns the current Cc field value.
1429func (m *Composer) GetCc() string {
1430	return m.ccInput.Value()
1431}
1432
1433// SetCc updates the Cc field with new content.
1434func (m *Composer) SetCc(cc string) {
1435	m.ccInput.SetValue(cc)
1436}
1437
1438// GetBcc returns the current Bcc field value.
1439func (m *Composer) GetBcc() string {
1440	return m.bccInput.Value()
1441}
1442
1443// SetBcc updates the Bcc field with new content.
1444func (m *Composer) SetBcc(bcc string) {
1445	m.bccInput.SetValue(bcc)
1446}
1447
1448// GetSubject returns the current Subject field value.
1449func (m *Composer) GetSubject() string {
1450	return m.subjectInput.Value()
1451}
1452
1453// SetSubject updates the Subject field with new content.
1454func (m *Composer) SetSubject(subject string) {
1455	m.subjectInput.SetValue(subject)
1456}
1457
1458// GetBody returns the current Body field value.
1459func (m *Composer) GetBody() string {
1460	return m.bodyInput.Value()
1461}
1462
1463// SetBody updates the Body field with new content.
1464func (m *Composer) SetBody(body string) {
1465	m.bodyInput.SetValue(body)
1466}
1467
1468// GetAttachmentPaths returns the current attachment paths.
1469func (m *Composer) GetAttachmentPaths() []string {
1470	return m.attachmentPaths
1471}
1472
1473// GetSignature returns the current signature value.
1474func (m *Composer) GetSignature() string {
1475	return m.signatureInput.Value()
1476}
1477
1478// SetReplyContext sets the reply context for the draft.
1479func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
1480	m.inReplyTo = inReplyTo
1481	m.references = references
1482}
1483
1484// SetQuotedText sets the hidden quoted text that will be appended when sending.
1485func (m *Composer) SetQuotedText(text string) {
1486	m.quotedText = text
1487}
1488
1489// GetQuotedText returns the hidden quoted text.
1490func (m *Composer) GetQuotedText() string {
1491	return m.quotedText
1492}
1493
1494// GetInReplyTo returns the In-Reply-To header value.
1495func (m *Composer) GetInReplyTo() string {
1496	return m.inReplyTo
1497}
1498
1499// GetReferences returns the References header values.
1500func (m *Composer) GetReferences() []string {
1501	return m.references
1502}
1503
1504// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
1505func (m *Composer) SetPluginStatus(status string) {
1506	m.pluginStatus = status
1507}
1508
1509// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1510func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1511	m.pluginKeyBindings = bindings
1512}
1513
1514// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
1515func (m *Composer) ShowPluginPrompt(placeholder string) {
1516	m.pluginPromptPlaceholder = placeholder
1517	m.pluginPromptInput = textinput.New()
1518	m.pluginPromptInput.Placeholder = placeholder
1519	m.pluginPromptInput.Prompt = "> "
1520	m.pluginPromptInput.CharLimit = 256
1521	m.pluginPromptInput.Focus()
1522	m.showPluginPrompt = true
1523}
1524
1525// HidePluginPrompt deactivates the plugin prompt overlay.
1526func (m *Composer) HidePluginPrompt() {
1527	m.showPluginPrompt = false
1528}
1529
1530// ToDraft converts the composer state to a Draft for saving.
1531func (m *Composer) ToDraft() config.Draft {
1532	return config.Draft{
1533		ID:              m.draftID,
1534		To:              m.toInput.Value(),
1535		Cc:              m.ccInput.Value(),
1536		Bcc:             m.bccInput.Value(),
1537		Subject:         m.subjectInput.Value(),
1538		Body:            m.bodyInput.Value(),
1539		AttachmentPaths: m.attachmentPaths,
1540		AccountID:       m.GetSelectedAccountID(),
1541		FromOverride:    m.fromInput.Value(),
1542		InReplyTo:       m.inReplyTo,
1543		References:      m.references,
1544		QuotedText:      m.quotedText,
1545	}
1546}
1547
1548// NewComposerFromDraft creates a composer from an existing draft.
1549func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
1550	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
1551	m.ccInput.SetValue(draft.Cc)
1552	m.bccInput.SetValue(draft.Bcc)
1553	m.draftID = draft.ID
1554	m.attachmentPaths = draft.AttachmentPaths
1555	m.attachmentNames = make(map[string]string, len(m.attachmentPaths))
1556	for _, path := range m.attachmentPaths {
1557		m.attachmentNames[path] = formatAttachmentName(path)
1558	}
1559	m.clampAttachmentCursor()
1560	if m.isCatchAllAccount() && draft.FromOverride != "" {
1561		m.fromInput.SetValue(draft.FromOverride)
1562	}
1563	m.inReplyTo = draft.InReplyTo
1564	m.references = draft.References
1565	m.quotedText = draft.QuotedText
1566	return m
1567}