composer.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"path/filepath"
  6	"strings"
  7
  8	"charm.land/bubbles/v2/textarea"
  9	"charm.land/bubbles/v2/textinput"
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/lipgloss/v2"
 12	"github.com/floatpane/matcha/config"
 13	"github.com/google/uuid"
 14)
 15
 16var (
 17	suggestionStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
 18	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 19	suggestionBoxStyle      = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
 20)
 21
 22// Styles for the UI
 23var (
 24	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 25	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
 26	noStyle             = lipgloss.NewStyle()
 27	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
 28	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 29	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
 30	fromSelectorStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 31	smimeToggleStyle    = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
 32)
 33
 34const (
 35	focusFrom = iota
 36	focusTo
 37	focusCc
 38	focusBcc
 39	focusSubject
 40	focusBody
 41	focusSignature
 42	focusAttachment
 43	focusEncryptSMIME
 44	focusSend
 45)
 46
 47// Composer model holds the state of the email composition UI.
 48type Composer struct {
 49	focusIndex      int
 50	toInput         textinput.Model
 51	ccInput         textinput.Model
 52	bccInput        textinput.Model
 53	subjectInput    textinput.Model
 54	bodyInput       textarea.Model
 55	signatureInput  textarea.Model
 56	attachmentPaths []string
 57	encryptSMIME    bool
 58	width           int
 59	height          int
 60	confirmingExit  bool
 61	hideTips        bool
 62
 63	// Multi-account support
 64	accounts           []config.Account
 65	selectedAccountIdx int
 66	showAccountPicker  bool
 67
 68	// Contact suggestions
 69	suggestions        []config.Contact
 70	selectedSuggestion int
 71	showSuggestions    bool
 72	lastToValue        string
 73
 74	// Draft persistence
 75	draftID string
 76
 77	// Reply context
 78	inReplyTo  string
 79	references []string
 80
 81	// Hidden quoted text (appended to body when sending, but not shown in editor)
 82	quotedText string
 83
 84	// Plugin status text shown in the help bar
 85	pluginStatus      string
 86	pluginKeyBindings []PluginKeyBinding
 87
 88	// Plugin prompt overlay
 89	showPluginPrompt        bool
 90	pluginPromptInput       textinput.Model
 91	pluginPromptPlaceholder string
 92}
 93
 94// NewComposer initializes a new composer model.
 95func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 96	m := &Composer{
 97		draftID:  uuid.New().String(),
 98		hideTips: hideTips,
 99	}
100
101	tiStyles := ThemedTextInputStyles()
102	taStyles := ThemedTextAreaStyles()
103
104	m.toInput = textinput.New()
105	m.toInput.Placeholder = t("composer.to_placeholder")
106	m.toInput.SetValue(to)
107	m.toInput.Prompt = "> "
108	m.toInput.CharLimit = 256
109	m.toInput.SetStyles(tiStyles)
110
111	m.ccInput = textinput.New()
112	m.ccInput.Placeholder = t("composer.cc_placeholder")
113	m.ccInput.Prompt = "> "
114	m.ccInput.CharLimit = 256
115	m.ccInput.SetStyles(tiStyles)
116
117	m.bccInput = textinput.New()
118	m.bccInput.Placeholder = t("composer.bcc_placeholder")
119	m.bccInput.Prompt = "> "
120	m.bccInput.CharLimit = 256
121	m.bccInput.SetStyles(tiStyles)
122
123	m.subjectInput = textinput.New()
124	m.subjectInput.Placeholder = t("composer.subject_placeholder")
125	m.subjectInput.SetValue(subject)
126	m.subjectInput.Prompt = "> "
127	m.subjectInput.CharLimit = 256
128	m.subjectInput.SetStyles(tiStyles)
129
130	m.bodyInput = textarea.New()
131	m.bodyInput.Placeholder = t("composer.body_placeholder")
132	m.bodyInput.SetValue(body)
133	m.bodyInput.Prompt = "> "
134	m.bodyInput.SetHeight(10)
135	m.bodyInput.SetStyles(taStyles)
136
137	m.signatureInput = textarea.New()
138	m.signatureInput.Placeholder = t("composer.signature_placeholder")
139	m.signatureInput.Prompt = "> "
140	m.signatureInput.SetHeight(3)
141	m.signatureInput.SetStyles(taStyles)
142	// Load default signature
143	if sig, err := config.LoadSignature(); err == nil && sig != "" {
144		m.signatureInput.SetValue(sig)
145	}
146
147	// Start focus on To field (From is selectable but not a text input)
148	m.focusIndex = focusTo
149	m.toInput.Focus()
150
151	return m
152}
153
154// NewComposerWithAccounts initializes a composer with multiple account support.
155func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
156	m := NewComposer("", to, subject, body, hideTips)
157	m.accounts = accounts
158
159	// Find the selected account index
160	for i, acc := range accounts {
161		if acc.ID == selectedAccountID {
162			m.selectedAccountIdx = i
163			break
164		}
165	}
166
167	return m
168}
169
170// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
171func (m *Composer) ResetConfirmation() {
172	m.confirmingExit = false
173}
174
175func (m *Composer) Init() tea.Cmd {
176	return textinput.Blink
177}
178
179func (m *Composer) getFromAddress() string {
180	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
181		return m.accounts[m.selectedAccountIdx].FormatFromHeader()
182	}
183	return ""
184}
185
186func (m *Composer) getSelectedAccount() *config.Account {
187	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
188		return &m.accounts[m.selectedAccountIdx]
189	}
190	return nil
191}
192
193func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
194	var cmds []tea.Cmd
195	var cmd tea.Cmd
196
197	switch msg := msg.(type) {
198	case tea.WindowSizeMsg:
199		m.width = msg.Width
200		m.height = msg.Height
201		inputWidth := msg.Width - 6
202		m.toInput.SetWidth(inputWidth)
203		m.ccInput.SetWidth(inputWidth)
204		m.bccInput.SetWidth(inputWidth)
205		m.subjectInput.SetWidth(inputWidth)
206		m.bodyInput.SetWidth(inputWidth)
207		m.signatureInput.SetWidth(inputWidth)
208		if msg.Height > 0 {
209			// Fixed rows: title, from, to, cc, bcc, subject, sig label,
210			// attachment, smime, button, blank, tip, help = 13
211			const fixedRows = 13
212			available := msg.Height - fixedRows
213			if available < 6 {
214				available = 6
215			}
216			bodyHeight := (available * 55) / 100
217			sigHeight := (available * 15) / 100
218			if bodyHeight < 3 {
219				bodyHeight = 3
220			}
221			if sigHeight < 2 {
222				sigHeight = 2
223			}
224			m.bodyInput.SetHeight(bodyHeight)
225			m.signatureInput.SetHeight(sigHeight)
226		}
227
228	case FileSelectedMsg:
229		// Avoid duplicates
230		for _, p := range m.attachmentPaths {
231			if p == msg.Path {
232				return m, nil
233			}
234		}
235		m.attachmentPaths = append(m.attachmentPaths, msg.Path)
236		return m, nil
237
238	case tea.KeyPressMsg:
239		// Handle contact suggestions mode
240		if m.showSuggestions && len(m.suggestions) > 0 {
241			switch msg.String() {
242			case "up", "ctrl+p":
243				if m.selectedSuggestion > 0 {
244					m.selectedSuggestion--
245				}
246				return m, nil
247			case "down", "ctrl+n":
248				if m.selectedSuggestion < len(m.suggestions)-1 {
249					m.selectedSuggestion++
250				}
251				return m, nil
252			case "tab", "enter":
253				// Select the suggestion
254				selected := m.suggestions[m.selectedSuggestion]
255
256				var newEmail string
257				if strings.Contains(selected.Email, ",") {
258					// It's a mailing list: insert just the addresses to maintain valid email formatting
259					newEmail = selected.Email
260				} else if selected.Name != "" && selected.Name != selected.Email {
261					newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
262				} else {
263					newEmail = selected.Email
264				}
265
266				parts := strings.Split(m.toInput.Value(), ",")
267				if len(parts) > 0 {
268					if len(parts) == 1 {
269						parts[0] = newEmail
270					} else {
271						parts[len(parts)-1] = " " + newEmail
272					}
273				} else {
274					parts = []string{newEmail}
275				}
276
277				finalValue := strings.Join(parts, ",")
278				if !strings.HasSuffix(finalValue, ", ") {
279					finalValue += ", "
280				}
281
282				m.toInput.SetValue(finalValue)
283				m.toInput.SetCursor(len(finalValue))
284				m.lastToValue = m.toInput.Value()
285				m.showSuggestions = false
286				m.suggestions = nil
287				return m, nil
288			case "esc":
289				m.showSuggestions = false
290				m.suggestions = nil
291				return m, nil
292			}
293			// For shift+tab, close suggestions and let it fall through to normal handling
294			if msg.String() == "shift+tab" {
295				m.showSuggestions = false
296				m.suggestions = nil
297			}
298		}
299
300		// Handle plugin prompt overlay
301		if m.showPluginPrompt {
302			switch msg.String() {
303			case "enter":
304				value := m.pluginPromptInput.Value()
305				m.showPluginPrompt = false
306				return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
307			case "esc":
308				m.showPluginPrompt = false
309				return m, func() tea.Msg { return PluginPromptCancelMsg{} }
310			default:
311				m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
312				return m, cmd
313			}
314		}
315
316		// Handle account picker mode
317		if m.showAccountPicker {
318			switch msg.String() {
319			case "up", "k":
320				if m.selectedAccountIdx > 0 {
321					m.selectedAccountIdx--
322				}
323			case "down", "j":
324				if m.selectedAccountIdx < len(m.accounts)-1 {
325					m.selectedAccountIdx++
326				}
327			case "enter":
328				m.showAccountPicker = false
329			case "esc":
330				m.showAccountPicker = false
331			}
332			return m, nil
333		}
334
335		if m.confirmingExit {
336			switch msg.String() {
337			case "y", "Y":
338				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
339			case "n", "N", "esc":
340				m.confirmingExit = false
341				return m, nil
342			default:
343				return m, nil
344			}
345		}
346
347		switch msg.String() {
348		case "ctrl+c":
349			return m, tea.Quit
350		case "ctrl+e":
351			return m, func() tea.Msg { return OpenEditorMsg{} }
352		case "esc":
353			m.confirmingExit = true
354			return m, nil
355
356		case "tab", "shift+tab":
357			if msg.String() == "shift+tab" {
358				m.focusIndex--
359			} else {
360				m.focusIndex++
361			}
362
363			maxFocus := focusSend
364			minFocus := focusFrom
365			// Skip From field if only one account (nothing to switch)
366			if len(m.accounts) <= 1 {
367				minFocus = focusTo
368			}
369
370			if m.focusIndex > maxFocus {
371				m.focusIndex = minFocus
372			} else if m.focusIndex < minFocus {
373				m.focusIndex = maxFocus
374			}
375
376			m.toInput.Blur()
377			m.ccInput.Blur()
378			m.bccInput.Blur()
379			m.subjectInput.Blur()
380			m.bodyInput.Blur()
381			m.signatureInput.Blur()
382
383			switch m.focusIndex {
384			case focusTo:
385				cmds = append(cmds, m.toInput.Focus())
386			case focusCc:
387				cmds = append(cmds, m.ccInput.Focus())
388			case focusBcc:
389				cmds = append(cmds, m.bccInput.Focus())
390			case focusSubject:
391				cmds = append(cmds, m.subjectInput.Focus())
392			case focusBody:
393				cmds = append(cmds, m.bodyInput.Focus())
394			case focusSignature:
395				cmds = append(cmds, m.signatureInput.Focus())
396			}
397			return m, tea.Batch(cmds...)
398
399		case "backspace", "delete", "d":
400			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
401				m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
402				return m, nil
403			}
404
405		case "enter", " ":
406			switch m.focusIndex {
407			case focusFrom:
408				if len(m.accounts) > 1 && msg.String() == "enter" {
409					m.showAccountPicker = true
410				}
411				return m, nil
412			case focusAttachment:
413				if msg.String() == "enter" {
414					return m, func() tea.Msg { return GoToFilePickerMsg{} }
415				}
416			case focusEncryptSMIME:
417				if msg.String() == "enter" || msg.String() == " " {
418					m.encryptSMIME = !m.encryptSMIME
419				}
420				return m, nil
421
422			case focusSend:
423				if msg.String() == "enter" {
424					acc := m.getSelectedAccount()
425					accountID := ""
426					if acc != nil {
427						accountID = acc.ID
428					}
429					return m, func() tea.Msg {
430						return SendEmailMsg{
431							To:              m.toInput.Value(),
432							Cc:              m.ccInput.Value(),
433							Bcc:             m.bccInput.Value(),
434							Subject:         m.subjectInput.Value(),
435							Body:            m.bodyInput.Value(),
436							AttachmentPaths: m.attachmentPaths,
437							AccountID:       accountID,
438							QuotedText:      m.quotedText,
439							InReplyTo:       m.inReplyTo,
440							References:      m.references,
441							Signature:       m.signatureInput.Value(),
442							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
443							EncryptSMIME:    m.encryptSMIME,
444							SignPGP:         acc != nil && acc.PGPSignByDefault,
445						}
446					}
447				}
448			}
449		}
450	}
451
452	switch m.focusIndex {
453	case focusTo:
454		m.toInput, cmd = m.toInput.Update(msg)
455		cmds = append(cmds, cmd)
456
457		// Check if To field value changed and update suggestions
458		currentValue := m.toInput.Value()
459		if currentValue != m.lastToValue {
460			m.lastToValue = currentValue
461
462			// Extract the last comma-separated part for searching
463			parts := strings.Split(currentValue, ",")
464			lastPart := strings.TrimSpace(parts[len(parts)-1])
465
466			if len(lastPart) >= 2 {
467				m.suggestions = config.SearchContacts(lastPart)
468				m.showSuggestions = len(m.suggestions) > 0
469				m.selectedSuggestion = 0
470			} else {
471				m.showSuggestions = false
472				m.suggestions = nil
473			}
474		}
475	case focusCc:
476		m.ccInput, cmd = m.ccInput.Update(msg)
477		cmds = append(cmds, cmd)
478	case focusBcc:
479		m.bccInput, cmd = m.bccInput.Update(msg)
480		cmds = append(cmds, cmd)
481	case focusSubject:
482		m.subjectInput, cmd = m.subjectInput.Update(msg)
483		cmds = append(cmds, cmd)
484	case focusBody:
485		m.bodyInput, cmd = m.bodyInput.Update(msg)
486		cmds = append(cmds, cmd)
487	case focusSignature:
488		m.signatureInput, cmd = m.signatureInput.Update(msg)
489		cmds = append(cmds, cmd)
490	}
491
492	return m, tea.Batch(cmds...)
493}
494
495func (m *Composer) View() tea.View {
496	var composerView strings.Builder
497	var button string
498
499	if m.focusIndex == focusSend {
500		button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]")
501	} else {
502		button = blurredStyle.Copy().Render("[ " + t("composer.send") + " ]")
503	}
504
505	// From field with account selector
506	fromAddr := m.getFromAddress()
507	var fromField string
508	if len(m.accounts) > 1 {
509		if m.focusIndex == focusFrom {
510			fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
511		} else {
512			fromField = blurredStyle.Render(fmt.Sprintf("  %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
513		}
514	} else if fromAddr != "" {
515		fromField = "  " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
516	} else {
517		fromField = blurredStyle.Render(fmt.Sprintf("  %s (%s)", t("composer.from"), t("composer.no_account")))
518	}
519
520	var attachmentField string
521	if len(m.attachmentPaths) == 0 {
522		attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
523		if m.focusIndex == focusAttachment {
524			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
525		} else {
526			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.attachments"), attachmentText))
527		}
528	} else {
529		var names []string
530		for _, p := range m.attachmentPaths {
531			names = append(names, filepath.Base(p))
532		}
533		attachmentText := strings.Join(names, ", ")
534		if m.focusIndex == focusAttachment {
535			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
536		} else {
537			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
538		}
539	}
540
541	encToggle := "[ ]"
542	if m.encryptSMIME {
543		encToggle = "[x]"
544	}
545	encField := blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.encrypt_smime"), encToggle))
546	if m.focusIndex == focusEncryptSMIME {
547		encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
548	}
549
550	// Build To field with suggestions
551	toFieldView := m.toInput.View()
552	if m.showSuggestions && len(m.suggestions) > 0 {
553		var suggestionsBuilder strings.Builder
554		for i, s := range m.suggestions {
555			display := s.Email
556			if s.Name != "" && s.Name != s.Email {
557				display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
558			}
559			if i == m.selectedSuggestion {
560				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
561			} else {
562				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
563			}
564		}
565		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
566	}
567
568	// Signature field label
569	var signatureLabel string
570	if m.focusIndex == focusSignature {
571		signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
572	} else {
573		signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
574	}
575
576	tip := ""
577	switch m.focusIndex {
578	case focusFrom:
579		tip = "Select the account to send from."
580	case focusTo:
581		tip = "Enter recipient email addresses."
582	case focusCc:
583		tip = "Carbon copy recipients."
584	case focusBcc:
585		tip = "Blind carbon copy recipients."
586	case focusSubject:
587		tip = "The subject line of your email."
588	case focusBody:
589		tip = "The main content of your email. Markdown and HTML are supported."
590	case focusSignature:
591		tip = "Your email signature. This will be appended to the end of the email."
592	case focusAttachment:
593		tip = "Enter: add file • backspace/d: remove last attachment"
594	case focusEncryptSMIME:
595		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
596	case focusSend:
597		tip = "Press Enter to send the email."
598	}
599
600	composerViewElements := []string{
601		t("composer.title"),
602		fromField,
603		toFieldView,
604		m.ccInput.View(),
605		m.bccInput.View(),
606		m.subjectInput.View(),
607		m.bodyInput.View(),
608		signatureLabel,
609		m.signatureInput.View(),
610		attachmentStyle.Render(attachmentField),
611		smimeToggleStyle.Render(encField),
612		button,
613		"",
614	}
615
616	if !m.hideTips && tip != "" {
617		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
618	}
619
620	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
621	helpText := t("composer.help")
622	for _, pk := range m.pluginKeyBindings {
623		helpText += " • " + pk.Key + ": " + pk.Description
624	}
625	if m.pluginStatus != "" {
626		helpText += " • " + m.pluginStatus
627	}
628	helpView := helpStyle.Render(helpText)
629
630	if m.height > 0 {
631		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
632		gap := m.height - currentHeight
633		if gap >= 0 {
634			mainContent += strings.Repeat("\n", gap+1)
635		} else {
636			mainContent += "\n"
637		}
638	} else {
639		mainContent += "\n\n"
640	}
641
642	composerView.WriteString(mainContent)
643	composerView.WriteString(helpView)
644
645	// Plugin prompt overlay
646	if m.showPluginPrompt {
647		dialog := DialogBoxStyle.Render(
648			lipgloss.JoinVertical(lipgloss.Left,
649				m.pluginPromptPlaceholder,
650				"",
651				m.pluginPromptInput.View(),
652				"",
653				HelpStyle.Render("enter: submit • esc: cancel"),
654			),
655		)
656		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
657	}
658
659	// Account picker overlay
660	if m.showAccountPicker {
661		var accountList strings.Builder
662		accountList.WriteString("Select Account:\n\n")
663		for i, acc := range m.accounts {
664			display := acc.GetSendAsEmail()
665			if acc.Name != "" {
666				display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
667			}
668			if i == m.selectedAccountIdx {
669				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
670			} else {
671				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
672			}
673			accountList.WriteString("\n")
674		}
675		accountList.WriteString("\n")
676		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
677
678		dialog := DialogBoxStyle.Render(accountList.String())
679		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
680	}
681
682	if m.confirmingExit {
683		dialog := DialogBoxStyle.Render(
684			lipgloss.JoinVertical(lipgloss.Center,
685				t("composer.exit_confirm"),
686				HelpStyle.Render("\n(y/n)"),
687			),
688		)
689		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
690	}
691
692	return tea.NewView(composerView.String())
693}
694
695// SetAccounts sets the available accounts for sending.
696func (m *Composer) SetAccounts(accounts []config.Account) {
697	m.accounts = accounts
698	if m.selectedAccountIdx >= len(accounts) {
699		m.selectedAccountIdx = 0
700	}
701}
702
703// SetSelectedAccount sets the selected account by ID.
704func (m *Composer) SetSelectedAccount(accountID string) {
705	for i, acc := range m.accounts {
706		if acc.ID == accountID {
707			m.selectedAccountIdx = i
708			return
709		}
710	}
711}
712
713// GetSelectedAccountID returns the ID of the currently selected account.
714func (m *Composer) GetSelectedAccountID() string {
715	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
716		return m.accounts[m.selectedAccountIdx].ID
717	}
718	return ""
719}
720
721// GetDraftID returns the draft ID for this composer.
722func (m *Composer) GetDraftID() string {
723	return m.draftID
724}
725
726// SetDraftID sets the draft ID (for loading existing drafts).
727func (m *Composer) SetDraftID(id string) {
728	m.draftID = id
729}
730
731// GetTo returns the current To field value.
732func (m *Composer) GetTo() string {
733	return m.toInput.Value()
734}
735
736// SetTo updates the To field with new content.
737func (m *Composer) SetTo(to string) {
738	m.toInput.SetValue(to)
739}
740
741// GetCc returns the current Cc field value.
742func (m *Composer) GetCc() string {
743	return m.ccInput.Value()
744}
745
746// SetCc updates the Cc field with new content.
747func (m *Composer) SetCc(cc string) {
748	m.ccInput.SetValue(cc)
749}
750
751// GetBcc returns the current Bcc field value.
752func (m *Composer) GetBcc() string {
753	return m.bccInput.Value()
754}
755
756// SetBcc updates the Bcc field with new content.
757func (m *Composer) SetBcc(bcc string) {
758	m.bccInput.SetValue(bcc)
759}
760
761// GetSubject returns the current Subject field value.
762func (m *Composer) GetSubject() string {
763	return m.subjectInput.Value()
764}
765
766// SetSubject updates the Subject field with new content.
767func (m *Composer) SetSubject(subject string) {
768	m.subjectInput.SetValue(subject)
769}
770
771// GetBody returns the current Body field value.
772func (m *Composer) GetBody() string {
773	return m.bodyInput.Value()
774}
775
776// SetBody updates the Body field with new content.
777func (m *Composer) SetBody(body string) {
778	m.bodyInput.SetValue(body)
779}
780
781// GetAttachmentPaths returns the current attachment paths.
782func (m *Composer) GetAttachmentPaths() []string {
783	return m.attachmentPaths
784}
785
786// GetSignature returns the current signature value.
787func (m *Composer) GetSignature() string {
788	return m.signatureInput.Value()
789}
790
791// SetReplyContext sets the reply context for the draft.
792func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
793	m.inReplyTo = inReplyTo
794	m.references = references
795}
796
797// SetQuotedText sets the hidden quoted text that will be appended when sending.
798func (m *Composer) SetQuotedText(text string) {
799	m.quotedText = text
800}
801
802// GetQuotedText returns the hidden quoted text.
803func (m *Composer) GetQuotedText() string {
804	return m.quotedText
805}
806
807// GetInReplyTo returns the In-Reply-To header value.
808func (m *Composer) GetInReplyTo() string {
809	return m.inReplyTo
810}
811
812// GetReferences returns the References header values.
813func (m *Composer) GetReferences() []string {
814	return m.references
815}
816
817// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
818func (m *Composer) SetPluginStatus(status string) {
819	m.pluginStatus = status
820}
821
822// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
823func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
824	m.pluginKeyBindings = bindings
825}
826
827// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
828func (m *Composer) ShowPluginPrompt(placeholder string) {
829	m.pluginPromptPlaceholder = placeholder
830	m.pluginPromptInput = textinput.New()
831	m.pluginPromptInput.Placeholder = placeholder
832	m.pluginPromptInput.Prompt = "> "
833	m.pluginPromptInput.CharLimit = 256
834	m.pluginPromptInput.Focus()
835	m.showPluginPrompt = true
836}
837
838// HidePluginPrompt deactivates the plugin prompt overlay.
839func (m *Composer) HidePluginPrompt() {
840	m.showPluginPrompt = false
841}
842
843// ToDraft converts the composer state to a Draft for saving.
844func (m *Composer) ToDraft() config.Draft {
845	return config.Draft{
846		ID:              m.draftID,
847		To:              m.toInput.Value(),
848		Cc:              m.ccInput.Value(),
849		Bcc:             m.bccInput.Value(),
850		Subject:         m.subjectInput.Value(),
851		Body:            m.bodyInput.Value(),
852		AttachmentPaths: m.attachmentPaths,
853		AccountID:       m.GetSelectedAccountID(),
854		InReplyTo:       m.inReplyTo,
855		References:      m.references,
856		QuotedText:      m.quotedText,
857	}
858}
859
860// NewComposerFromDraft creates a composer from an existing draft.
861func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
862	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
863	m.ccInput.SetValue(draft.Cc)
864	m.bccInput.SetValue(draft.Bcc)
865	m.draftID = draft.ID
866	m.attachmentPaths = draft.AttachmentPaths
867	m.inReplyTo = draft.InReplyTo
868	m.references = draft.References
869	m.quotedText = draft.QuotedText
870	return m
871}