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 "esc":
335			m.confirmingExit = true
336			return m, nil
337
338		case "tab", "shift+tab":
339			if msg.String() == "shift+tab" {
340				m.focusIndex--
341			} else {
342				m.focusIndex++
343			}
344
345			maxFocus := focusSend
346			minFocus := focusFrom
347			// Skip From field if only one account (nothing to switch)
348			if len(m.accounts) <= 1 {
349				minFocus = focusTo
350			}
351
352			if m.focusIndex > maxFocus {
353				m.focusIndex = minFocus
354			} else if m.focusIndex < minFocus {
355				m.focusIndex = maxFocus
356			}
357
358			m.toInput.Blur()
359			m.ccInput.Blur()
360			m.bccInput.Blur()
361			m.subjectInput.Blur()
362			m.bodyInput.Blur()
363			m.signatureInput.Blur()
364
365			switch m.focusIndex {
366			case focusTo:
367				cmds = append(cmds, m.toInput.Focus())
368			case focusCc:
369				cmds = append(cmds, m.ccInput.Focus())
370			case focusBcc:
371				cmds = append(cmds, m.bccInput.Focus())
372			case focusSubject:
373				cmds = append(cmds, m.subjectInput.Focus())
374			case focusBody:
375				cmds = append(cmds, m.bodyInput.Focus())
376			case focusSignature:
377				cmds = append(cmds, m.signatureInput.Focus())
378			}
379			return m, tea.Batch(cmds...)
380
381		case "backspace", "delete", "d":
382			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
383				m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
384				return m, nil
385			}
386
387		case "enter", " ":
388			switch m.focusIndex {
389			case focusFrom:
390				if len(m.accounts) > 1 && msg.String() == "enter" {
391					m.showAccountPicker = true
392				}
393				return m, nil
394			case focusAttachment:
395				if msg.String() == "enter" {
396					return m, func() tea.Msg { return GoToFilePickerMsg{} }
397				}
398			case focusEncryptSMIME:
399				if msg.String() == "enter" || msg.String() == " " {
400					m.encryptSMIME = !m.encryptSMIME
401				}
402				return m, nil
403			case focusSend:
404				if msg.String() == "enter" {
405					acc := m.getSelectedAccount()
406					accountID := ""
407					if acc != nil {
408						accountID = acc.ID
409					}
410					return m, func() tea.Msg {
411						return SendEmailMsg{
412							To:              m.toInput.Value(),
413							Cc:              m.ccInput.Value(),
414							Bcc:             m.bccInput.Value(),
415							Subject:         m.subjectInput.Value(),
416							Body:            m.bodyInput.Value(),
417							AttachmentPaths: m.attachmentPaths,
418							AccountID:       accountID,
419							QuotedText:      m.quotedText,
420							InReplyTo:       m.inReplyTo,
421							References:      m.references,
422							Signature:       m.signatureInput.Value(),
423							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
424							EncryptSMIME:    m.encryptSMIME,
425						}
426					}
427				}
428			}
429		}
430	}
431
432	switch m.focusIndex {
433	case focusTo:
434		m.toInput, cmd = m.toInput.Update(msg)
435		cmds = append(cmds, cmd)
436
437		// Check if To field value changed and update suggestions
438		currentValue := m.toInput.Value()
439		if currentValue != m.lastToValue {
440			m.lastToValue = currentValue
441
442			// Extract the last comma-separated part for searching
443			parts := strings.Split(currentValue, ",")
444			lastPart := strings.TrimSpace(parts[len(parts)-1])
445
446			if len(lastPart) >= 2 {
447				m.suggestions = config.SearchContacts(lastPart)
448				m.showSuggestions = len(m.suggestions) > 0
449				m.selectedSuggestion = 0
450			} else {
451				m.showSuggestions = false
452				m.suggestions = nil
453			}
454		}
455	case focusCc:
456		m.ccInput, cmd = m.ccInput.Update(msg)
457		cmds = append(cmds, cmd)
458	case focusBcc:
459		m.bccInput, cmd = m.bccInput.Update(msg)
460		cmds = append(cmds, cmd)
461	case focusSubject:
462		m.subjectInput, cmd = m.subjectInput.Update(msg)
463		cmds = append(cmds, cmd)
464	case focusBody:
465		m.bodyInput, cmd = m.bodyInput.Update(msg)
466		cmds = append(cmds, cmd)
467	case focusSignature:
468		m.signatureInput, cmd = m.signatureInput.Update(msg)
469		cmds = append(cmds, cmd)
470	}
471
472	return m, tea.Batch(cmds...)
473}
474
475func (m *Composer) View() tea.View {
476	var composerView strings.Builder
477	var button string
478
479	if m.focusIndex == focusSend {
480		button = focusedButton
481	} else {
482		button = blurredButton
483	}
484
485	// From field with account selector
486	fromAddr := m.getFromAddress()
487	var fromField string
488	if len(m.accounts) > 1 {
489		if m.focusIndex == focusFrom {
490			fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr))
491		} else {
492			fromField = blurredStyle.Render(fmt.Sprintf("  From: %s [switchable]", fromAddr))
493		}
494	} else if fromAddr != "" {
495		fromField = "  From: " + emailRecipientStyle.Render(fromAddr)
496	} else {
497		fromField = blurredStyle.Render("  From: (no account configured)")
498	}
499
500	var attachmentField string
501	if len(m.attachmentPaths) == 0 {
502		attachmentText := "None (Enter to add)"
503		if m.focusIndex == focusAttachment {
504			attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments: %s", attachmentText))
505		} else {
506			attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachments: %s", attachmentText))
507		}
508	} else {
509		var names []string
510		for _, p := range m.attachmentPaths {
511			names = append(names, filepath.Base(p))
512		}
513		attachmentText := strings.Join(names, ", ")
514		if m.focusIndex == focusAttachment {
515			attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments (%d): %s", len(m.attachmentPaths), attachmentText))
516		} else {
517			attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachments (%d): %s", len(m.attachmentPaths), attachmentText))
518		}
519	}
520
521	encToggle := "[ ]"
522	if m.encryptSMIME {
523		encToggle = "[x]"
524	}
525	encField := blurredStyle.Render(fmt.Sprintf("  Encrypt Email (S/MIME): %s", encToggle))
526	if m.focusIndex == focusEncryptSMIME {
527		encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle))
528	}
529
530	// Build To field with suggestions
531	toFieldView := m.toInput.View()
532	if m.showSuggestions && len(m.suggestions) > 0 {
533		var suggestionsBuilder strings.Builder
534		for i, s := range m.suggestions {
535			display := s.Email
536			if s.Name != "" && s.Name != s.Email {
537				display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
538			}
539			if i == m.selectedSuggestion {
540				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
541			} else {
542				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
543			}
544		}
545		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
546	}
547
548	// Signature field label
549	var signatureLabel string
550	if m.focusIndex == focusSignature {
551		signatureLabel = focusedStyle.Render("Signature:")
552	} else {
553		signatureLabel = blurredStyle.Render("Signature:")
554	}
555
556	tip := ""
557	switch m.focusIndex {
558	case focusFrom:
559		tip = "Select the account to send from."
560	case focusTo:
561		tip = "Enter recipient email addresses."
562	case focusCc:
563		tip = "Carbon copy recipients."
564	case focusBcc:
565		tip = "Blind carbon copy recipients."
566	case focusSubject:
567		tip = "The subject line of your email."
568	case focusBody:
569		tip = "The main content of your email. Markdown and HTML are supported."
570	case focusSignature:
571		tip = "Your email signature. This will be appended to the end of the email."
572	case focusAttachment:
573		tip = "Enter: add file • backspace/d: remove last attachment"
574	case focusEncryptSMIME:
575		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
576	case focusSend:
577		tip = "Press Enter to send the email."
578	}
579
580	composerViewElements := []string{
581		"Compose New Email",
582		fromField,
583		toFieldView,
584		m.ccInput.View(),
585		m.bccInput.View(),
586		m.subjectInput.View(),
587		m.bodyInput.View(),
588		signatureLabel,
589		m.signatureInput.View(),
590		attachmentStyle.Render(attachmentField),
591		smimeToggleStyle.Render(encField),
592		button,
593		"",
594	}
595
596	if !m.hideTips && tip != "" {
597		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
598	}
599
600	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
601	helpText := "Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit"
602	if m.pluginStatus != "" {
603		helpText += " • " + m.pluginStatus
604	}
605	helpView := helpStyle.Render(helpText)
606
607	if m.height > 0 {
608		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
609		gap := m.height - currentHeight
610		if gap >= 0 {
611			mainContent += strings.Repeat("\n", gap+1)
612		} else {
613			mainContent += "\n"
614		}
615	} else {
616		mainContent += "\n\n"
617	}
618
619	composerView.WriteString(mainContent)
620	composerView.WriteString(helpView)
621
622	// Account picker overlay
623	if m.showAccountPicker {
624		var accountList strings.Builder
625		accountList.WriteString("Select Account:\n\n")
626		for i, acc := range m.accounts {
627			display := acc.FetchEmail
628			if acc.Name != "" {
629				display = fmt.Sprintf("%s (%s)", acc.Name, acc.FetchEmail)
630			}
631			if i == m.selectedAccountIdx {
632				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
633			} else {
634				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
635			}
636			accountList.WriteString("\n")
637		}
638		accountList.WriteString("\n")
639		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
640
641		dialog := DialogBoxStyle.Render(accountList.String())
642		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
643	}
644
645	if m.confirmingExit {
646		dialog := DialogBoxStyle.Render(
647			lipgloss.JoinVertical(lipgloss.Center,
648				"Are you sure you want to exit? This draft will be saved",
649				HelpStyle.Render("\n(y/n)"),
650			),
651		)
652		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
653	}
654
655	return tea.NewView(composerView.String())
656}
657
658// SetAccounts sets the available accounts for sending.
659func (m *Composer) SetAccounts(accounts []config.Account) {
660	m.accounts = accounts
661	if m.selectedAccountIdx >= len(accounts) {
662		m.selectedAccountIdx = 0
663	}
664}
665
666// SetSelectedAccount sets the selected account by ID.
667func (m *Composer) SetSelectedAccount(accountID string) {
668	for i, acc := range m.accounts {
669		if acc.ID == accountID {
670			m.selectedAccountIdx = i
671			return
672		}
673	}
674}
675
676// GetSelectedAccountID returns the ID of the currently selected account.
677func (m *Composer) GetSelectedAccountID() string {
678	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
679		return m.accounts[m.selectedAccountIdx].ID
680	}
681	return ""
682}
683
684// GetDraftID returns the draft ID for this composer.
685func (m *Composer) GetDraftID() string {
686	return m.draftID
687}
688
689// SetDraftID sets the draft ID (for loading existing drafts).
690func (m *Composer) SetDraftID(id string) {
691	m.draftID = id
692}
693
694// GetTo returns the current To field value.
695func (m *Composer) GetTo() string {
696	return m.toInput.Value()
697}
698
699// GetSubject returns the current Subject field value.
700func (m *Composer) GetSubject() string {
701	return m.subjectInput.Value()
702}
703
704// GetBody returns the current Body field value.
705func (m *Composer) GetBody() string {
706	return m.bodyInput.Value()
707}
708
709// GetAttachmentPaths returns the current attachment paths.
710func (m *Composer) GetAttachmentPaths() []string {
711	return m.attachmentPaths
712}
713
714// GetSignature returns the current signature value.
715func (m *Composer) GetSignature() string {
716	return m.signatureInput.Value()
717}
718
719// SetReplyContext sets the reply context for the draft.
720func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
721	m.inReplyTo = inReplyTo
722	m.references = references
723}
724
725// SetQuotedText sets the hidden quoted text that will be appended when sending.
726func (m *Composer) SetQuotedText(text string) {
727	m.quotedText = text
728}
729
730// GetQuotedText returns the hidden quoted text.
731func (m *Composer) GetQuotedText() string {
732	return m.quotedText
733}
734
735// GetInReplyTo returns the In-Reply-To header value.
736func (m *Composer) GetInReplyTo() string {
737	return m.inReplyTo
738}
739
740// GetReferences returns the References header values.
741func (m *Composer) GetReferences() []string {
742	return m.references
743}
744
745// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
746func (m *Composer) SetPluginStatus(status string) {
747	m.pluginStatus = status
748}
749
750// ToDraft converts the composer state to a Draft for saving.
751func (m *Composer) ToDraft() config.Draft {
752	return config.Draft{
753		ID:              m.draftID,
754		To:              m.toInput.Value(),
755		Cc:              m.ccInput.Value(),
756		Bcc:             m.bccInput.Value(),
757		Subject:         m.subjectInput.Value(),
758		Body:            m.bodyInput.Value(),
759		AttachmentPaths: m.attachmentPaths,
760		AccountID:       m.GetSelectedAccountID(),
761		InReplyTo:       m.inReplyTo,
762		References:      m.references,
763		QuotedText:      m.quotedText,
764	}
765}
766
767// NewComposerFromDraft creates a composer from an existing draft.
768func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
769	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
770	m.ccInput.SetValue(draft.Cc)
771	m.bccInput.SetValue(draft.Bcc)
772	m.draftID = draft.ID
773	m.attachmentPaths = draft.AttachmentPaths
774	m.inReplyTo = draft.InReplyTo
775	m.references = draft.References
776	m.quotedText = draft.QuotedText
777	return m
778}