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