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