composer.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/textarea"
 10	"charm.land/bubbles/v2/textinput"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/floatpane/matcha/config"
 14	"github.com/google/uuid"
 15)
 16
 17var (
 18	suggestionStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
 19	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 20	suggestionBoxStyle      = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
 21)
 22
 23// Styles for the UI
 24var (
 25	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 26	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
 27	noStyle             = lipgloss.NewStyle()
 28	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
 29	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 30	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
 31	fromSelectorStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
 32	smimeToggleStyle    = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
 33)
 34
 35const (
 36	focusFrom = iota
 37	focusTo
 38	focusCc
 39	focusBcc
 40	focusSubject
 41	focusBody
 42	focusSignature
 43	focusAttachment
 44	focusEncryptSMIME
 45	focusSend
 46)
 47
 48// Composer model holds the state of the email composition UI.
 49type Composer struct {
 50	focusIndex      int
 51	toInput         textinput.Model
 52	ccInput         textinput.Model
 53	bccInput        textinput.Model
 54	subjectInput    textinput.Model
 55	bodyInput       textarea.Model
 56	signatureInput  textarea.Model
 57	attachmentPaths []string
 58	attachmentNames map[string]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	fromInput          textinput.Model // editable From when account is catch-all
 70
 71	// Contact suggestions
 72	suggestions        []config.Contact
 73	selectedSuggestion int
 74	showSuggestions    bool
 75	lastToValue        string
 76
 77	// Draft persistence
 78	draftID string
 79
 80	// Reply context
 81	inReplyTo  string
 82	references []string
 83
 84	// Hidden quoted text (appended to body when sending, but not shown in editor)
 85	quotedText string
 86
 87	// Plugin status text shown in the help bar
 88	pluginStatus      string
 89	pluginKeyBindings []PluginKeyBinding
 90
 91	// Plugin prompt overlay
 92	showPluginPrompt        bool
 93	pluginPromptInput       textinput.Model
 94	pluginPromptPlaceholder string
 95}
 96
 97// NewComposer initializes a new composer model.
 98func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 99	m := &Composer{
100		draftID:         uuid.New().String(),
101		hideTips:        hideTips,
102		attachmentNames: make(map[string]string),
103	}
104
105	tiStyles := ThemedTextInputStyles()
106	taStyles := ThemedTextAreaStyles()
107
108	m.toInput = textinput.New()
109	m.toInput.Placeholder = t("composer.to_placeholder")
110	m.toInput.SetValue(to)
111	m.toInput.Prompt = "> "
112	m.toInput.CharLimit = 256
113	m.toInput.SetStyles(tiStyles)
114
115	m.ccInput = textinput.New()
116	m.ccInput.Placeholder = t("composer.cc_placeholder")
117	m.ccInput.Prompt = "> "
118	m.ccInput.CharLimit = 256
119	m.ccInput.SetStyles(tiStyles)
120
121	m.bccInput = textinput.New()
122	m.bccInput.Placeholder = t("composer.bcc_placeholder")
123	m.bccInput.Prompt = "> "
124	m.bccInput.CharLimit = 256
125	m.bccInput.SetStyles(tiStyles)
126
127	m.subjectInput = textinput.New()
128	m.subjectInput.Placeholder = t("composer.subject_placeholder")
129	m.subjectInput.SetValue(subject)
130	m.subjectInput.Prompt = "> "
131	m.subjectInput.CharLimit = 256
132	m.subjectInput.SetStyles(tiStyles)
133
134	m.bodyInput = textarea.New()
135	m.bodyInput.Placeholder = t("composer.body_placeholder")
136	m.bodyInput.SetValue(body)
137	m.bodyInput.Prompt = "> "
138	m.bodyInput.SetHeight(10)
139	m.bodyInput.SetStyles(taStyles)
140
141	m.signatureInput = textarea.New()
142	m.signatureInput.Placeholder = t("composer.signature_placeholder")
143	m.signatureInput.Prompt = "> "
144	m.signatureInput.SetHeight(3)
145	m.signatureInput.SetStyles(taStyles)
146	m.updateSignature()
147
148	m.fromInput = textinput.New()
149	m.fromInput.Placeholder = t("composer.from_placeholder")
150	m.fromInput.Prompt = "> "
151	m.fromInput.CharLimit = 256
152	m.fromInput.SetStyles(tiStyles)
153
154	// Start focus on To field (From is selectable but not a text input)
155	m.focusIndex = focusTo
156	m.toInput.Focus()
157
158	return m
159}
160
161// updateSignature updates the signature input based on the current selected account.
162func (m *Composer) updateSignature() {
163	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
164		acc := &m.accounts[m.selectedAccountIdx]
165		if sig, err := config.LoadSignatureForAccount(acc); err == nil && sig != "" {
166			m.signatureInput.SetValue(sig)
167		} else if sig, err := config.LoadSignature(); err == nil && sig != "" {
168			m.signatureInput.SetValue(sig)
169		} else {
170			m.signatureInput.SetValue("")
171		}
172		// Seed the editable From address for catch-all accounts.
173		m.fromInput.SetValue(acc.FormatFromHeader())
174		return
175	}
176
177	if sig, err := config.LoadSignature(); err == nil && sig != "" {
178		m.signatureInput.SetValue(sig)
179	} else {
180		m.signatureInput.SetValue("")
181	}
182}
183
184// NewComposerWithAccounts initializes a composer with multiple account support.
185func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
186	m := NewComposer("", to, subject, body, hideTips)
187	m.accounts = accounts
188
189	// Find the selected account index
190	for i, acc := range accounts {
191		if acc.ID == selectedAccountID {
192			m.selectedAccountIdx = i
193			break
194		}
195	}
196	m.updateSignature()
197
198	return m
199}
200
201// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
202func (m *Composer) ResetConfirmation() {
203	m.confirmingExit = false
204}
205
206// SetFromOverride pre-fills the editable From field (used for catch-all replies).
207func (m *Composer) SetFromOverride(addr string) {
208	m.fromInput.SetValue(addr)
209}
210
211func (m *Composer) Init() tea.Cmd {
212	return textinput.Blink
213}
214
215func (m *Composer) getFromAddress() string {
216	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
217		return m.accounts[m.selectedAccountIdx].FormatFromHeader()
218	}
219	return ""
220}
221
222func (m *Composer) isCatchAllAccount() bool {
223	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
224		return m.accounts[m.selectedAccountIdx].CatchAll
225	}
226	return false
227}
228
229func (m *Composer) getSelectedAccount() *config.Account {
230	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
231		return &m.accounts[m.selectedAccountIdx]
232	}
233	return nil
234}
235
236func formatAttachmentName(path string) string {
237	name := filepath.Base(path)
238	info, err := os.Stat(path)
239	if err != nil || info.IsDir() {
240		return name
241	}
242	return fmt.Sprintf("%s (%s)", name, tfs(info.Size()))
243}
244
245func (m *Composer) attachmentDisplayName(path string) string {
246	if name, ok := m.attachmentNames[path]; ok {
247		return name
248	}
249	return filepath.Base(path)
250}
251
252func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
253	var cmds []tea.Cmd
254	var cmd tea.Cmd
255
256	switch msg := msg.(type) {
257	case tea.WindowSizeMsg:
258		m.width = msg.Width
259		m.height = msg.Height
260		inputWidth := msg.Width - 6
261		m.toInput.SetWidth(inputWidth)
262		m.ccInput.SetWidth(inputWidth)
263		m.bccInput.SetWidth(inputWidth)
264		m.subjectInput.SetWidth(inputWidth)
265		m.bodyInput.SetWidth(inputWidth)
266		m.signatureInput.SetWidth(inputWidth)
267		if msg.Height > 0 {
268			// Fixed rows: title, from, to, cc, bcc, subject, sig label,
269			// attachment, smime, button, blank, tip, help = 13
270			const fixedRows = 13
271			available := msg.Height - fixedRows
272			if available < 6 {
273				available = 6
274			}
275			bodyHeight := (available * 55) / 100
276			sigHeight := (available * 15) / 100
277			if bodyHeight < 3 {
278				bodyHeight = 3
279			}
280			if sigHeight < 2 {
281				sigHeight = 2
282			}
283			m.bodyInput.SetHeight(bodyHeight)
284			m.signatureInput.SetHeight(sigHeight)
285		}
286
287	case FileSelectedMsg:
288		// Avoid duplicates and add all selected paths
289		for _, newPath := range msg.Paths {
290			exists := false
291			for _, p := range m.attachmentPaths {
292				if p == newPath {
293					exists = true
294					break
295				}
296			}
297			if !exists {
298				m.attachmentPaths = append(m.attachmentPaths, newPath)
299				m.attachmentNames[newPath] = formatAttachmentName(newPath)
300			}
301		}
302		return m, nil
303
304	case tea.KeyPressMsg:
305		// Handle contact suggestions mode
306		if m.showSuggestions && len(m.suggestions) > 0 {
307			switch msg.String() {
308			case "up", "ctrl+p":
309				if m.selectedSuggestion > 0 {
310					m.selectedSuggestion--
311				}
312				return m, nil
313			case "down", "ctrl+n":
314				if m.selectedSuggestion < len(m.suggestions)-1 {
315					m.selectedSuggestion++
316				}
317				return m, nil
318			case "tab", "enter":
319				// Select the suggestion
320				selected := m.suggestions[m.selectedSuggestion]
321
322				var newEmail string
323				if strings.Contains(selected.Email, ",") {
324					// It's a mailing list: insert just the addresses to maintain valid email formatting
325					newEmail = selected.Email
326				} else if selected.Name != "" && selected.Name != selected.Email {
327					newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
328				} else {
329					newEmail = selected.Email
330				}
331
332				parts := strings.Split(m.toInput.Value(), ",")
333				if len(parts) > 0 {
334					if len(parts) == 1 {
335						parts[0] = newEmail
336					} else {
337						parts[len(parts)-1] = " " + newEmail
338					}
339				} else {
340					parts = []string{newEmail}
341				}
342
343				finalValue := strings.Join(parts, ",")
344				if !strings.HasSuffix(finalValue, ", ") {
345					finalValue += ", "
346				}
347
348				m.toInput.SetValue(finalValue)
349				m.toInput.SetCursor(len(finalValue))
350				m.lastToValue = m.toInput.Value()
351				m.showSuggestions = false
352				m.suggestions = nil
353				return m, nil
354			case "esc":
355				m.showSuggestions = false
356				m.suggestions = nil
357				return m, nil
358			}
359			// For prev-field key, close suggestions and let it fall through to normal handling
360			if msg.String() == config.Keybinds.Composer.PrevField {
361				m.showSuggestions = false
362				m.suggestions = nil
363			}
364		}
365
366		// Handle plugin prompt overlay
367		if m.showPluginPrompt {
368			switch msg.String() {
369			case "enter":
370				value := m.pluginPromptInput.Value()
371				m.showPluginPrompt = false
372				return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
373			case "esc":
374				m.showPluginPrompt = false
375				return m, func() tea.Msg { return PluginPromptCancelMsg{} }
376			default:
377				m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
378				return m, cmd
379			}
380		}
381
382		// Handle account picker mode
383		if m.showAccountPicker {
384			switch msg.String() {
385			case "up", "k":
386				if m.selectedAccountIdx > 0 {
387					m.selectedAccountIdx--
388					m.updateSignature()
389				}
390			case "down", "j":
391				if m.selectedAccountIdx < len(m.accounts)-1 {
392					m.selectedAccountIdx++
393					m.updateSignature()
394				}
395			case "enter":
396				m.showAccountPicker = false
397			case "esc":
398				m.showAccountPicker = false
399			}
400			return m, nil
401		}
402
403		if m.confirmingExit {
404			switch msg.String() {
405			case "y", "Y":
406				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
407			case "n", "N", "esc":
408				m.confirmingExit = false
409				return m, nil
410			default:
411				return m, nil
412			}
413		}
414
415		kb := config.Keybinds
416		switch msg.String() {
417		case kb.Global.Quit:
418			return m, tea.Quit
419		case kb.Composer.ExternalEditor:
420			return m, func() tea.Msg { return OpenEditorMsg{} }
421		case kb.Global.Cancel:
422			m.confirmingExit = true
423			return m, nil
424
425		case kb.Composer.NextField, kb.Composer.PrevField:
426			if msg.String() == kb.Composer.PrevField {
427				m.focusIndex--
428			} else {
429				m.focusIndex++
430			}
431
432			maxFocus := focusSend
433			minFocus := focusFrom
434			// Skip From field if only one non-catch-all account (nothing to switch or edit)
435			if len(m.accounts) <= 1 && !m.isCatchAllAccount() {
436				minFocus = focusTo
437			}
438
439			if m.focusIndex > maxFocus {
440				m.focusIndex = minFocus
441			} else if m.focusIndex < minFocus {
442				m.focusIndex = maxFocus
443			}
444
445			m.fromInput.Blur()
446			m.toInput.Blur()
447			m.ccInput.Blur()
448			m.bccInput.Blur()
449			m.subjectInput.Blur()
450			m.bodyInput.Blur()
451			m.signatureInput.Blur()
452
453			switch m.focusIndex {
454			case focusFrom:
455				if m.isCatchAllAccount() {
456					cmds = append(cmds, m.fromInput.Focus())
457				}
458			case focusTo:
459				cmds = append(cmds, m.toInput.Focus())
460			case focusCc:
461				cmds = append(cmds, m.ccInput.Focus())
462			case focusBcc:
463				cmds = append(cmds, m.bccInput.Focus())
464			case focusSubject:
465				cmds = append(cmds, m.subjectInput.Focus())
466			case focusBody:
467				cmds = append(cmds, m.bodyInput.Focus())
468			case focusSignature:
469				cmds = append(cmds, m.signatureInput.Focus())
470			}
471			return m, tea.Batch(cmds...)
472
473		case "backspace", "delete", "d":
474			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
475				delete(m.attachmentNames, m.attachmentPaths[len(m.attachmentPaths)-1])
476				m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
477				return m, nil
478			}
479
480		case "enter", " ":
481			switch m.focusIndex {
482			case focusFrom:
483				if msg.String() == "enter" && len(m.accounts) > 1 {
484					m.showAccountPicker = true
485					return m, nil
486				}
487				if m.isCatchAllAccount() && msg.String() == " " {
488					break
489				}
490				return m, nil
491			case focusAttachment:
492				if msg.String() == "enter" {
493					return m, func() tea.Msg { return GoToFilePickerMsg{} }
494				}
495			case focusEncryptSMIME:
496				if msg.String() == "enter" || msg.String() == " " {
497					m.encryptSMIME = !m.encryptSMIME
498				}
499				return m, nil
500
501			case focusSend:
502				if msg.String() == "enter" {
503					acc := m.getSelectedAccount()
504					accountID := ""
505					if acc != nil {
506						accountID = acc.ID
507					}
508					fromOverride := ""
509					if m.isCatchAllAccount() {
510						fromOverride = m.fromInput.Value()
511					}
512					return m, func() tea.Msg {
513						return SendEmailMsg{
514							To:              m.toInput.Value(),
515							Cc:              m.ccInput.Value(),
516							Bcc:             m.bccInput.Value(),
517							Subject:         m.subjectInput.Value(),
518							Body:            m.bodyInput.Value(),
519							AttachmentPaths: m.attachmentPaths,
520							AccountID:       accountID,
521							FromOverride:    fromOverride,
522							QuotedText:      m.quotedText,
523							InReplyTo:       m.inReplyTo,
524							References:      m.references,
525							Signature:       m.signatureInput.Value(),
526							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
527							EncryptSMIME:    m.encryptSMIME,
528							SignPGP:         acc != nil && acc.PGPSignByDefault,
529						}
530					}
531				}
532			}
533		}
534	}
535
536	switch m.focusIndex {
537	case focusFrom:
538		if m.isCatchAllAccount() {
539			m.fromInput, cmd = m.fromInput.Update(msg)
540			cmds = append(cmds, cmd)
541		}
542	case focusTo:
543		m.toInput, cmd = m.toInput.Update(msg)
544		cmds = append(cmds, cmd)
545
546		// Check if To field value changed and update suggestions
547		currentValue := m.toInput.Value()
548		if currentValue != m.lastToValue {
549			m.lastToValue = currentValue
550
551			// Extract the last comma-separated part for searching
552			parts := strings.Split(currentValue, ",")
553			lastPart := strings.TrimSpace(parts[len(parts)-1])
554
555			if len(lastPart) >= 2 {
556				m.suggestions = config.SearchContacts(lastPart)
557				m.showSuggestions = len(m.suggestions) > 0
558				m.selectedSuggestion = 0
559			} else {
560				m.showSuggestions = false
561				m.suggestions = nil
562			}
563		}
564	case focusCc:
565		m.ccInput, cmd = m.ccInput.Update(msg)
566		cmds = append(cmds, cmd)
567	case focusBcc:
568		m.bccInput, cmd = m.bccInput.Update(msg)
569		cmds = append(cmds, cmd)
570	case focusSubject:
571		m.subjectInput, cmd = m.subjectInput.Update(msg)
572		cmds = append(cmds, cmd)
573	case focusBody:
574		m.bodyInput, cmd = m.bodyInput.Update(msg)
575		cmds = append(cmds, cmd)
576	case focusSignature:
577		m.signatureInput, cmd = m.signatureInput.Update(msg)
578		cmds = append(cmds, cmd)
579	}
580
581	return m, tea.Batch(cmds...)
582}
583
584func (m *Composer) View() tea.View {
585	var composerView strings.Builder
586	var button string
587
588	if m.focusIndex == focusSend {
589		button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]")
590	} else {
591		button = blurredStyle.Copy().Render("[ " + t("composer.send") + " ]")
592	}
593
594	// From field with account selector
595	fromAddr := m.getFromAddress()
596	var fromField string
597	if m.isCatchAllAccount() {
598		fromAddrView := m.fromInput.View()
599		if len(m.accounts) > 1 {
600			if m.focusIndex == focusFrom {
601				fromField = focusedStyle.Render(fmt.Sprintf("> %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.enter_to_switch")+"]")
602			} else {
603				fromField = blurredStyle.Render(fmt.Sprintf("  %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.switchable")+"]")
604			}
605		} else {
606			fromField = "  " + t("composer.from") + " " + fromAddrView
607		}
608	} else if len(m.accounts) > 1 {
609		if m.focusIndex == focusFrom {
610			fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
611		} else {
612			fromField = blurredStyle.Render(fmt.Sprintf("  %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
613		}
614	} else if fromAddr != "" {
615		fromField = "  " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
616	} else {
617		fromField = blurredStyle.Render(fmt.Sprintf("  %s (%s)", t("composer.from"), t("composer.no_account")))
618	}
619
620	var attachmentField string
621	if len(m.attachmentPaths) == 0 {
622		attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
623		if m.focusIndex == focusAttachment {
624			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
625		} else {
626			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.attachments"), attachmentText))
627		}
628	} else {
629		var names []string
630		for _, p := range m.attachmentPaths {
631			names = append(names, m.attachmentDisplayName(p))
632		}
633		attachmentText := strings.Join(names, ", ")
634		if m.focusIndex == focusAttachment {
635			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
636		} else {
637			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
638		}
639	}
640
641	encToggle := "[ ]"
642	if m.encryptSMIME {
643		encToggle = "[x]"
644	}
645	encField := blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.encrypt_smime"), encToggle))
646	if m.focusIndex == focusEncryptSMIME {
647		encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
648	}
649
650	// Build To field with suggestions
651	toFieldView := m.toInput.View()
652	if m.showSuggestions && len(m.suggestions) > 0 {
653		var suggestionsBuilder strings.Builder
654		for i, s := range m.suggestions {
655			display := s.Email
656			if s.Name != "" && s.Name != s.Email {
657				display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
658			}
659			if i == m.selectedSuggestion {
660				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
661			} else {
662				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
663			}
664		}
665		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
666	}
667
668	// Signature field label
669	var signatureLabel string
670	if m.focusIndex == focusSignature {
671		signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
672	} else {
673		signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
674	}
675
676	tip := ""
677	switch m.focusIndex {
678	case focusFrom:
679		tip = "Select the account to send from."
680	case focusTo:
681		tip = "Enter recipient email addresses."
682	case focusCc:
683		tip = "Carbon copy recipients."
684	case focusBcc:
685		tip = "Blind carbon copy recipients."
686	case focusSubject:
687		tip = "The subject line of your email."
688	case focusBody:
689		tip = "The main content of your email. Markdown and HTML are supported."
690	case focusSignature:
691		tip = "Your email signature. This will be appended to the end of the email."
692	case focusAttachment:
693		tip = "Enter: add file • backspace/d: remove last attachment"
694	case focusEncryptSMIME:
695		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
696	case focusSend:
697		tip = "Press Enter to send the email."
698	}
699
700	composerViewElements := []string{
701		t("composer.title"),
702		fromField,
703		toFieldView,
704		m.ccInput.View(),
705		m.bccInput.View(),
706		m.subjectInput.View(),
707		m.bodyInput.View(),
708		signatureLabel,
709		m.signatureInput.View(),
710		attachmentStyle.Render(attachmentField),
711		smimeToggleStyle.Render(encField),
712		button,
713		"",
714	}
715
716	if !m.hideTips && tip != "" {
717		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
718	}
719
720	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
721	helpText := t("composer.help")
722	for _, pk := range m.pluginKeyBindings {
723		helpText += " • " + pk.Key + ": " + pk.Description
724	}
725	if m.pluginStatus != "" {
726		helpText += " • " + m.pluginStatus
727	}
728	helpView := helpStyle.Render(helpText)
729
730	if m.height > 0 {
731		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
732		gap := m.height - currentHeight
733		if gap >= 0 {
734			mainContent += strings.Repeat("\n", gap+1)
735		} else {
736			mainContent += "\n"
737		}
738	} else {
739		mainContent += "\n\n"
740	}
741
742	composerView.WriteString(mainContent)
743	composerView.WriteString(helpView)
744
745	// Plugin prompt overlay
746	if m.showPluginPrompt {
747		dialog := DialogBoxStyle.Render(
748			lipgloss.JoinVertical(lipgloss.Left,
749				m.pluginPromptPlaceholder,
750				"",
751				m.pluginPromptInput.View(),
752				"",
753				HelpStyle.Render("enter: submit • esc: cancel"),
754			),
755		)
756		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
757	}
758
759	// Account picker overlay
760	if m.showAccountPicker {
761		var accountList strings.Builder
762		accountList.WriteString("Select Account:\n\n")
763		for i, acc := range m.accounts {
764			display := acc.GetSendAsEmail()
765			if acc.Name != "" {
766				display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
767			}
768			if i == m.selectedAccountIdx {
769				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
770			} else {
771				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
772			}
773			accountList.WriteString("\n")
774		}
775		accountList.WriteString("\n")
776		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
777
778		dialog := DialogBoxStyle.Render(accountList.String())
779		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
780	}
781
782	if m.confirmingExit {
783		dialog := DialogBoxStyle.Render(
784			lipgloss.JoinVertical(lipgloss.Center,
785				t("composer.exit_confirm"),
786				HelpStyle.Render("\n(y/n)"),
787			),
788		)
789		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
790	}
791
792	return tea.NewView(composerView.String())
793}
794
795// SetAccounts sets the available accounts for sending.
796func (m *Composer) SetAccounts(accounts []config.Account) {
797	m.accounts = accounts
798	if m.selectedAccountIdx >= len(accounts) {
799		m.selectedAccountIdx = 0
800	}
801	m.updateSignature()
802}
803
804// SetSelectedAccount sets the selected account by ID.
805func (m *Composer) SetSelectedAccount(accountID string) {
806	for i, acc := range m.accounts {
807		if acc.ID == accountID {
808			m.selectedAccountIdx = i
809			m.updateSignature()
810			return
811		}
812	}
813}
814
815// GetSelectedAccountID returns the ID of the currently selected account.
816func (m *Composer) GetSelectedAccountID() string {
817	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
818		return m.accounts[m.selectedAccountIdx].ID
819	}
820	return ""
821}
822
823// GetDraftID returns the draft ID for this composer.
824func (m *Composer) GetDraftID() string {
825	return m.draftID
826}
827
828// SetDraftID sets the draft ID (for loading existing drafts).
829func (m *Composer) SetDraftID(id string) {
830	m.draftID = id
831}
832
833// GetTo returns the current To field value.
834func (m *Composer) GetTo() string {
835	return m.toInput.Value()
836}
837
838// SetTo updates the To field with new content.
839func (m *Composer) SetTo(to string) {
840	m.toInput.SetValue(to)
841}
842
843// GetCc returns the current Cc field value.
844func (m *Composer) GetCc() string {
845	return m.ccInput.Value()
846}
847
848// SetCc updates the Cc field with new content.
849func (m *Composer) SetCc(cc string) {
850	m.ccInput.SetValue(cc)
851}
852
853// GetBcc returns the current Bcc field value.
854func (m *Composer) GetBcc() string {
855	return m.bccInput.Value()
856}
857
858// SetBcc updates the Bcc field with new content.
859func (m *Composer) SetBcc(bcc string) {
860	m.bccInput.SetValue(bcc)
861}
862
863// GetSubject returns the current Subject field value.
864func (m *Composer) GetSubject() string {
865	return m.subjectInput.Value()
866}
867
868// SetSubject updates the Subject field with new content.
869func (m *Composer) SetSubject(subject string) {
870	m.subjectInput.SetValue(subject)
871}
872
873// GetBody returns the current Body field value.
874func (m *Composer) GetBody() string {
875	return m.bodyInput.Value()
876}
877
878// SetBody updates the Body field with new content.
879func (m *Composer) SetBody(body string) {
880	m.bodyInput.SetValue(body)
881}
882
883// GetAttachmentPaths returns the current attachment paths.
884func (m *Composer) GetAttachmentPaths() []string {
885	return m.attachmentPaths
886}
887
888// GetSignature returns the current signature value.
889func (m *Composer) GetSignature() string {
890	return m.signatureInput.Value()
891}
892
893// SetReplyContext sets the reply context for the draft.
894func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
895	m.inReplyTo = inReplyTo
896	m.references = references
897}
898
899// SetQuotedText sets the hidden quoted text that will be appended when sending.
900func (m *Composer) SetQuotedText(text string) {
901	m.quotedText = text
902}
903
904// GetQuotedText returns the hidden quoted text.
905func (m *Composer) GetQuotedText() string {
906	return m.quotedText
907}
908
909// GetInReplyTo returns the In-Reply-To header value.
910func (m *Composer) GetInReplyTo() string {
911	return m.inReplyTo
912}
913
914// GetReferences returns the References header values.
915func (m *Composer) GetReferences() []string {
916	return m.references
917}
918
919// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
920func (m *Composer) SetPluginStatus(status string) {
921	m.pluginStatus = status
922}
923
924// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
925func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
926	m.pluginKeyBindings = bindings
927}
928
929// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
930func (m *Composer) ShowPluginPrompt(placeholder string) {
931	m.pluginPromptPlaceholder = placeholder
932	m.pluginPromptInput = textinput.New()
933	m.pluginPromptInput.Placeholder = placeholder
934	m.pluginPromptInput.Prompt = "> "
935	m.pluginPromptInput.CharLimit = 256
936	m.pluginPromptInput.Focus()
937	m.showPluginPrompt = true
938}
939
940// HidePluginPrompt deactivates the plugin prompt overlay.
941func (m *Composer) HidePluginPrompt() {
942	m.showPluginPrompt = false
943}
944
945// ToDraft converts the composer state to a Draft for saving.
946func (m *Composer) ToDraft() config.Draft {
947	return config.Draft{
948		ID:              m.draftID,
949		To:              m.toInput.Value(),
950		Cc:              m.ccInput.Value(),
951		Bcc:             m.bccInput.Value(),
952		Subject:         m.subjectInput.Value(),
953		Body:            m.bodyInput.Value(),
954		AttachmentPaths: m.attachmentPaths,
955		AccountID:       m.GetSelectedAccountID(),
956		FromOverride:    m.fromInput.Value(),
957		InReplyTo:       m.inReplyTo,
958		References:      m.references,
959		QuotedText:      m.quotedText,
960	}
961}
962
963// NewComposerFromDraft creates a composer from an existing draft.
964func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
965	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
966	m.ccInput.SetValue(draft.Cc)
967	m.bccInput.SetValue(draft.Bcc)
968	m.draftID = draft.ID
969	m.attachmentPaths = draft.AttachmentPaths
970	m.attachmentNames = make(map[string]string, len(m.attachmentPaths))
971	for _, path := range m.attachmentPaths {
972		m.attachmentNames[path] = formatAttachmentName(path)
973	}
974	if m.isCatchAllAccount() && draft.FromOverride != "" {
975		m.fromInput.SetValue(draft.FromOverride)
976	}
977	m.inReplyTo = draft.InReplyTo
978	m.references = draft.References
979	m.quotedText = draft.QuotedText
980	return m
981}