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