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