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}
 89
 90// NewComposer initializes a new composer model.
 91func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 92	m := &Composer{
 93		draftID:  uuid.New().String(),
 94		hideTips: hideTips,
 95	}
 96
 97	tiStyles := ThemedTextInputStyles()
 98	taStyles := ThemedTextAreaStyles()
 99
100	m.toInput = textinput.New()
101	m.toInput.Placeholder = "To"
102	m.toInput.SetValue(to)
103	m.toInput.Prompt = "> "
104	m.toInput.CharLimit = 256
105	m.toInput.SetStyles(tiStyles)
106
107	m.ccInput = textinput.New()
108	m.ccInput.Placeholder = "Cc"
109	m.ccInput.Prompt = "> "
110	m.ccInput.CharLimit = 256
111	m.ccInput.SetStyles(tiStyles)
112
113	m.bccInput = textinput.New()
114	m.bccInput.Placeholder = "Bcc"
115	m.bccInput.Prompt = "> "
116	m.bccInput.CharLimit = 256
117	m.bccInput.SetStyles(tiStyles)
118
119	m.subjectInput = textinput.New()
120	m.subjectInput.Placeholder = "Subject"
121	m.subjectInput.SetValue(subject)
122	m.subjectInput.Prompt = "> "
123	m.subjectInput.CharLimit = 256
124	m.subjectInput.SetStyles(tiStyles)
125
126	m.bodyInput = textarea.New()
127	m.bodyInput.Placeholder = "Body (Markdown supported)..."
128	m.bodyInput.SetValue(body)
129	m.bodyInput.Prompt = "> "
130	m.bodyInput.SetHeight(10)
131	m.bodyInput.SetStyles(taStyles)
132
133	m.signatureInput = textarea.New()
134	m.signatureInput.Placeholder = "Signature (optional)..."
135	m.signatureInput.Prompt = "> "
136	m.signatureInput.SetHeight(3)
137	m.signatureInput.SetStyles(taStyles)
138	// Load default signature
139	if sig, err := config.LoadSignature(); err == nil && sig != "" {
140		m.signatureInput.SetValue(sig)
141	}
142
143	// Start focus on To field (From is selectable but not a text input)
144	m.focusIndex = focusTo
145	m.toInput.Focus()
146
147	return m
148}
149
150// NewComposerWithAccounts initializes a composer with multiple account support.
151func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
152	m := NewComposer("", to, subject, body, hideTips)
153	m.accounts = accounts
154
155	// Find the selected account index
156	for i, acc := range accounts {
157		if acc.ID == selectedAccountID {
158			m.selectedAccountIdx = i
159			break
160		}
161	}
162
163	return m
164}
165
166// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
167func (m *Composer) ResetConfirmation() {
168	m.confirmingExit = false
169}
170
171func (m *Composer) Init() tea.Cmd {
172	return textinput.Blink
173}
174
175func (m *Composer) getFromAddress() string {
176	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
177		acc := m.accounts[m.selectedAccountIdx]
178		if acc.Name != "" {
179			return fmt.Sprintf("%s <%s>", acc.Name, acc.FetchEmail)
180		}
181		return acc.FetchEmail
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 account picker mode
301		if m.showAccountPicker {
302			switch msg.String() {
303			case "up", "k":
304				if m.selectedAccountIdx > 0 {
305					m.selectedAccountIdx--
306				}
307			case "down", "j":
308				if m.selectedAccountIdx < len(m.accounts)-1 {
309					m.selectedAccountIdx++
310				}
311			case "enter":
312				m.showAccountPicker = false
313			case "esc":
314				m.showAccountPicker = false
315			}
316			return m, nil
317		}
318
319		if m.confirmingExit {
320			switch msg.String() {
321			case "y", "Y":
322				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
323			case "n", "N", "esc":
324				m.confirmingExit = false
325				return m, nil
326			default:
327				return m, nil
328			}
329		}
330
331		switch msg.String() {
332		case "ctrl+c":
333			return m, tea.Quit
334		case "ctrl+e":
335			return m, func() tea.Msg { return OpenEditorMsg{} }
336		case "esc":
337			m.confirmingExit = true
338			return m, nil
339
340		case "tab", "shift+tab":
341			if msg.String() == "shift+tab" {
342				m.focusIndex--
343			} else {
344				m.focusIndex++
345			}
346
347			maxFocus := focusSend
348			minFocus := focusFrom
349			// Skip From field if only one account (nothing to switch)
350			if len(m.accounts) <= 1 {
351				minFocus = focusTo
352			}
353
354			if m.focusIndex > maxFocus {
355				m.focusIndex = minFocus
356			} else if m.focusIndex < minFocus {
357				m.focusIndex = maxFocus
358			}
359
360			m.toInput.Blur()
361			m.ccInput.Blur()
362			m.bccInput.Blur()
363			m.subjectInput.Blur()
364			m.bodyInput.Blur()
365			m.signatureInput.Blur()
366
367			switch m.focusIndex {
368			case focusTo:
369				cmds = append(cmds, m.toInput.Focus())
370			case focusCc:
371				cmds = append(cmds, m.ccInput.Focus())
372			case focusBcc:
373				cmds = append(cmds, m.bccInput.Focus())
374			case focusSubject:
375				cmds = append(cmds, m.subjectInput.Focus())
376			case focusBody:
377				cmds = append(cmds, m.bodyInput.Focus())
378			case focusSignature:
379				cmds = append(cmds, m.signatureInput.Focus())
380			}
381			return m, tea.Batch(cmds...)
382
383		case "backspace", "delete", "d":
384			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
385				m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
386				return m, nil
387			}
388
389		case "enter", " ":
390			switch m.focusIndex {
391			case focusFrom:
392				if len(m.accounts) > 1 && msg.String() == "enter" {
393					m.showAccountPicker = true
394				}
395				return m, nil
396			case focusAttachment:
397				if msg.String() == "enter" {
398					return m, func() tea.Msg { return GoToFilePickerMsg{} }
399				}
400			case focusEncryptSMIME:
401				if msg.String() == "enter" || msg.String() == " " {
402					m.encryptSMIME = !m.encryptSMIME
403				}
404				return m, nil
405
406			case focusSend:
407				if msg.String() == "enter" {
408					acc := m.getSelectedAccount()
409					accountID := ""
410					if acc != nil {
411						accountID = acc.ID
412					}
413					return m, func() tea.Msg {
414						return SendEmailMsg{
415							To:              m.toInput.Value(),
416							Cc:              m.ccInput.Value(),
417							Bcc:             m.bccInput.Value(),
418							Subject:         m.subjectInput.Value(),
419							Body:            m.bodyInput.Value(),
420							AttachmentPaths: m.attachmentPaths,
421							AccountID:       accountID,
422							QuotedText:      m.quotedText,
423							InReplyTo:       m.inReplyTo,
424							References:      m.references,
425							Signature:       m.signatureInput.Value(),
426							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
427							EncryptSMIME:    m.encryptSMIME,
428							SignPGP:         acc != nil && acc.PGPSignByDefault,
429						}
430					}
431				}
432			}
433		}
434	}
435
436	switch m.focusIndex {
437	case focusTo:
438		m.toInput, cmd = m.toInput.Update(msg)
439		cmds = append(cmds, cmd)
440
441		// Check if To field value changed and update suggestions
442		currentValue := m.toInput.Value()
443		if currentValue != m.lastToValue {
444			m.lastToValue = currentValue
445
446			// Extract the last comma-separated part for searching
447			parts := strings.Split(currentValue, ",")
448			lastPart := strings.TrimSpace(parts[len(parts)-1])
449
450			if len(lastPart) >= 2 {
451				m.suggestions = config.SearchContacts(lastPart)
452				m.showSuggestions = len(m.suggestions) > 0
453				m.selectedSuggestion = 0
454			} else {
455				m.showSuggestions = false
456				m.suggestions = nil
457			}
458		}
459	case focusCc:
460		m.ccInput, cmd = m.ccInput.Update(msg)
461		cmds = append(cmds, cmd)
462	case focusBcc:
463		m.bccInput, cmd = m.bccInput.Update(msg)
464		cmds = append(cmds, cmd)
465	case focusSubject:
466		m.subjectInput, cmd = m.subjectInput.Update(msg)
467		cmds = append(cmds, cmd)
468	case focusBody:
469		m.bodyInput, cmd = m.bodyInput.Update(msg)
470		cmds = append(cmds, cmd)
471	case focusSignature:
472		m.signatureInput, cmd = m.signatureInput.Update(msg)
473		cmds = append(cmds, cmd)
474	}
475
476	return m, tea.Batch(cmds...)
477}
478
479func (m *Composer) View() tea.View {
480	var composerView strings.Builder
481	var button string
482
483	if m.focusIndex == focusSend {
484		button = focusedButton
485	} else {
486		button = blurredButton
487	}
488
489	// From field with account selector
490	fromAddr := m.getFromAddress()
491	var fromField string
492	if len(m.accounts) > 1 {
493		if m.focusIndex == focusFrom {
494			fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr))
495		} else {
496			fromField = blurredStyle.Render(fmt.Sprintf("  From: %s [switchable]", fromAddr))
497		}
498	} else if fromAddr != "" {
499		fromField = "  From: " + emailRecipientStyle.Render(fromAddr)
500	} else {
501		fromField = blurredStyle.Render("  From: (no account configured)")
502	}
503
504	var attachmentField string
505	if len(m.attachmentPaths) == 0 {
506		attachmentText := "None (Enter to add)"
507		if m.focusIndex == focusAttachment {
508			attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments: %s", attachmentText))
509		} else {
510			attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachments: %s", attachmentText))
511		}
512	} else {
513		var names []string
514		for _, p := range m.attachmentPaths {
515			names = append(names, filepath.Base(p))
516		}
517		attachmentText := strings.Join(names, ", ")
518		if m.focusIndex == focusAttachment {
519			attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments (%d): %s", len(m.attachmentPaths), attachmentText))
520		} else {
521			attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachments (%d): %s", len(m.attachmentPaths), attachmentText))
522		}
523	}
524
525	encToggle := "[ ]"
526	if m.encryptSMIME {
527		encToggle = "[x]"
528	}
529	encField := blurredStyle.Render(fmt.Sprintf("  Encrypt Email (S/MIME): %s", encToggle))
530	if m.focusIndex == focusEncryptSMIME {
531		encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle))
532	}
533
534	// Build To field with suggestions
535	toFieldView := m.toInput.View()
536	if m.showSuggestions && len(m.suggestions) > 0 {
537		var suggestionsBuilder strings.Builder
538		for i, s := range m.suggestions {
539			display := s.Email
540			if s.Name != "" && s.Name != s.Email {
541				display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
542			}
543			if i == m.selectedSuggestion {
544				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
545			} else {
546				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
547			}
548		}
549		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
550	}
551
552	// Signature field label
553	var signatureLabel string
554	if m.focusIndex == focusSignature {
555		signatureLabel = focusedStyle.Render("Signature:")
556	} else {
557		signatureLabel = blurredStyle.Render("Signature:")
558	}
559
560	tip := ""
561	switch m.focusIndex {
562	case focusFrom:
563		tip = "Select the account to send from."
564	case focusTo:
565		tip = "Enter recipient email addresses."
566	case focusCc:
567		tip = "Carbon copy recipients."
568	case focusBcc:
569		tip = "Blind carbon copy recipients."
570	case focusSubject:
571		tip = "The subject line of your email."
572	case focusBody:
573		tip = "The main content of your email. Markdown and HTML are supported."
574	case focusSignature:
575		tip = "Your email signature. This will be appended to the end of the email."
576	case focusAttachment:
577		tip = "Enter: add file • backspace/d: remove last attachment"
578	case focusEncryptSMIME:
579		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
580	case focusSend:
581		tip = "Press Enter to send the email."
582	}
583
584	composerViewElements := []string{
585		"Compose New Email",
586		fromField,
587		toFieldView,
588		m.ccInput.View(),
589		m.bccInput.View(),
590		m.subjectInput.View(),
591		m.bodyInput.View(),
592		signatureLabel,
593		m.signatureInput.View(),
594		attachmentStyle.Render(attachmentField),
595		smimeToggleStyle.Render(encField),
596		button,
597		"",
598	}
599
600	if !m.hideTips && tip != "" {
601		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
602	}
603
604	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
605	helpText := "Markdown/HTML • tab/shift+tab: navigate • ctrl+e: $EDITOR • esc: save draft & exit"
606	if m.pluginStatus != "" {
607		helpText += " • " + m.pluginStatus
608	}
609	helpView := helpStyle.Render(helpText)
610
611	if m.height > 0 {
612		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
613		gap := m.height - currentHeight
614		if gap >= 0 {
615			mainContent += strings.Repeat("\n", gap+1)
616		} else {
617			mainContent += "\n"
618		}
619	} else {
620		mainContent += "\n\n"
621	}
622
623	composerView.WriteString(mainContent)
624	composerView.WriteString(helpView)
625
626	// Account picker overlay
627	if m.showAccountPicker {
628		var accountList strings.Builder
629		accountList.WriteString("Select Account:\n\n")
630		for i, acc := range m.accounts {
631			display := acc.FetchEmail
632			if acc.Name != "" {
633				display = fmt.Sprintf("%s (%s)", acc.Name, acc.FetchEmail)
634			}
635			if i == m.selectedAccountIdx {
636				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
637			} else {
638				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
639			}
640			accountList.WriteString("\n")
641		}
642		accountList.WriteString("\n")
643		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
644
645		dialog := DialogBoxStyle.Render(accountList.String())
646		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
647	}
648
649	if m.confirmingExit {
650		dialog := DialogBoxStyle.Render(
651			lipgloss.JoinVertical(lipgloss.Center,
652				"Are you sure you want to exit? This draft will be saved",
653				HelpStyle.Render("\n(y/n)"),
654			),
655		)
656		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
657	}
658
659	return tea.NewView(composerView.String())
660}
661
662// SetAccounts sets the available accounts for sending.
663func (m *Composer) SetAccounts(accounts []config.Account) {
664	m.accounts = accounts
665	if m.selectedAccountIdx >= len(accounts) {
666		m.selectedAccountIdx = 0
667	}
668}
669
670// SetSelectedAccount sets the selected account by ID.
671func (m *Composer) SetSelectedAccount(accountID string) {
672	for i, acc := range m.accounts {
673		if acc.ID == accountID {
674			m.selectedAccountIdx = i
675			return
676		}
677	}
678}
679
680// GetSelectedAccountID returns the ID of the currently selected account.
681func (m *Composer) GetSelectedAccountID() string {
682	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
683		return m.accounts[m.selectedAccountIdx].ID
684	}
685	return ""
686}
687
688// GetDraftID returns the draft ID for this composer.
689func (m *Composer) GetDraftID() string {
690	return m.draftID
691}
692
693// SetDraftID sets the draft ID (for loading existing drafts).
694func (m *Composer) SetDraftID(id string) {
695	m.draftID = id
696}
697
698// GetTo returns the current To field value.
699func (m *Composer) GetTo() string {
700	return m.toInput.Value()
701}
702
703// SetTo updates the To field with new content.
704func (m *Composer) SetTo(to string) {
705	m.toInput.SetValue(to)
706}
707
708// GetCc returns the current Cc field value.
709func (m *Composer) GetCc() string {
710	return m.ccInput.Value()
711}
712
713// SetCc updates the Cc field with new content.
714func (m *Composer) SetCc(cc string) {
715	m.ccInput.SetValue(cc)
716}
717
718// GetBcc returns the current Bcc field value.
719func (m *Composer) GetBcc() string {
720	return m.bccInput.Value()
721}
722
723// SetBcc updates the Bcc field with new content.
724func (m *Composer) SetBcc(bcc string) {
725	m.bccInput.SetValue(bcc)
726}
727
728// GetSubject returns the current Subject field value.
729func (m *Composer) GetSubject() string {
730	return m.subjectInput.Value()
731}
732
733// SetSubject updates the Subject field with new content.
734func (m *Composer) SetSubject(subject string) {
735	m.subjectInput.SetValue(subject)
736}
737
738// GetBody returns the current Body field value.
739func (m *Composer) GetBody() string {
740	return m.bodyInput.Value()
741}
742
743// SetBody updates the Body field with new content.
744func (m *Composer) SetBody(body string) {
745	m.bodyInput.SetValue(body)
746}
747
748// GetAttachmentPaths returns the current attachment paths.
749func (m *Composer) GetAttachmentPaths() []string {
750	return m.attachmentPaths
751}
752
753// GetSignature returns the current signature value.
754func (m *Composer) GetSignature() string {
755	return m.signatureInput.Value()
756}
757
758// SetReplyContext sets the reply context for the draft.
759func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
760	m.inReplyTo = inReplyTo
761	m.references = references
762}
763
764// SetQuotedText sets the hidden quoted text that will be appended when sending.
765func (m *Composer) SetQuotedText(text string) {
766	m.quotedText = text
767}
768
769// GetQuotedText returns the hidden quoted text.
770func (m *Composer) GetQuotedText() string {
771	return m.quotedText
772}
773
774// GetInReplyTo returns the In-Reply-To header value.
775func (m *Composer) GetInReplyTo() string {
776	return m.inReplyTo
777}
778
779// GetReferences returns the References header values.
780func (m *Composer) GetReferences() []string {
781	return m.references
782}
783
784// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
785func (m *Composer) SetPluginStatus(status string) {
786	m.pluginStatus = status
787}
788
789// ToDraft converts the composer state to a Draft for saving.
790func (m *Composer) ToDraft() config.Draft {
791	return config.Draft{
792		ID:              m.draftID,
793		To:              m.toInput.Value(),
794		Cc:              m.ccInput.Value(),
795		Bcc:             m.bccInput.Value(),
796		Subject:         m.subjectInput.Value(),
797		Body:            m.bodyInput.Value(),
798		AttachmentPaths: m.attachmentPaths,
799		AccountID:       m.GetSelectedAccountID(),
800		InReplyTo:       m.inReplyTo,
801		References:      m.references,
802		QuotedText:      m.quotedText,
803	}
804}
805
806// NewComposerFromDraft creates a composer from an existing draft.
807func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
808	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
809	m.ccInput.SetValue(draft.Cc)
810	m.bccInput.SetValue(draft.Bcc)
811	m.draftID = draft.ID
812	m.attachmentPaths = draft.AttachmentPaths
813	m.inReplyTo = draft.InReplyTo
814	m.references = draft.References
815	m.quotedText = draft.QuotedText
816	return m
817}