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