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