1package tui
2
3import (
4 "fmt"
5 "net/mail"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10 "unicode"
11
12 "charm.land/bubbles/v2/textarea"
13 "charm.land/bubbles/v2/textinput"
14 tea "charm.land/bubbletea/v2"
15 "charm.land/lipgloss/v2"
16 overlay "github.com/floatpane/bubble-overlay"
17 "github.com/floatpane/matcha/config"
18 "github.com/floatpane/matcha/spellcheck"
19 "github.com/google/uuid"
20)
21
22// spellcheckReadyMsg is delivered when the background spellcheck loader
23// finishes (either downloading the default dictionary or loading an
24// already-installed one).
25type spellcheckReadyMsg struct {
26 checker *spellcheck.Checker
27}
28
29var (
30 suggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
31 selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
32 suggestionBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
33)
34
35// Styles for the UI
36var (
37 focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
38 blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
39 helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
40 emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
41 attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
42 smimeToggleStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
43 composerErrorStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("196"))
44)
45
46const (
47 focusFrom = iota
48 focusTo
49 focusCc
50 focusBcc
51 focusSubject
52 focusBody
53 focusSignature
54 focusAttachment
55 focusEncryptSMIME
56 focusSend
57)
58
59type hideComposerNoticeMsg struct{}
60
61// Composer model holds the state of the email composition UI.
62type Composer struct {
63 focusIndex int
64 toInput textinput.Model
65 ccInput textinput.Model
66 bccInput textinput.Model
67 fromError string
68 toError string
69 ccError string
70 bccError string
71 subjectInput textinput.Model
72 bodyInput textarea.Model
73 signatureInput textarea.Model
74 attachmentPaths []string
75 attachmentNames map[string]string
76 attachmentCursor int
77 encryptSMIME bool
78 width int
79 height int
80 confirmingExit bool
81 showNotice bool
82 noticeText string
83 hideTips bool
84
85 // Multi-account support
86 accounts []config.Account
87 selectedAccountIdx int
88 showAccountPicker bool
89 fromInput textinput.Model // editable From when account is catch-all
90
91 // Contact suggestions
92 suggestions []config.Contact
93 selectedSuggestion int
94 showSuggestions bool
95 lastToValue string
96
97 // Draft persistence
98 draftID string
99
100 // Reply context
101 inReplyTo string
102 references []string
103
104 // Hidden quoted text (appended to body when sending, but not shown in editor)
105 quotedText string
106
107 // Plugin status text shown in the help bar
108 pluginStatus string
109 pluginKeyBindings []PluginKeyBinding
110
111 // Plugin prompt overlay
112 showPluginPrompt bool
113 pluginPromptInput textinput.Model
114 pluginPromptPlaceholder string
115
116 // Spellcheck (loaded asynchronously; nil until ready).
117 spellChecker *spellcheck.Checker
118 spellSuggestions []string
119 spellSelected int
120 spellShow bool
121 spellWordOnLine int // index of the logical line containing the word
122 spellWordLineStart int // byte offset of the word within its logical line
123 spellWordLineEnd int // byte offset of the word's end within its logical line
124 spellWord string // the misspelled word (as currently in body)
125 spellLastBody string // last body value we computed suggestions for
126 spellLastCursorRow int
127 spellLastCursorCol int
128 disableSpellcheck bool
129 disableSpellSuggestions bool
130}
131
132// NewComposer initializes a new composer model.
133func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
134 m := &Composer{
135 draftID: uuid.New().String(),
136 hideTips: hideTips,
137 attachmentNames: make(map[string]string),
138 }
139
140 tiStyles := ThemedTextInputStyles()
141 taStyles := ThemedTextAreaStyles()
142
143 m.toInput = textinput.New()
144 m.toInput.Placeholder = t("composer.to_placeholder")
145 m.toInput.SetValue(to)
146 m.toInput.Prompt = "> "
147 m.toInput.CharLimit = 256
148 m.toInput.SetStyles(tiStyles)
149
150 m.ccInput = textinput.New()
151 m.ccInput.Placeholder = t("composer.cc_placeholder")
152 m.ccInput.Prompt = "> "
153 m.ccInput.CharLimit = 256
154 m.ccInput.SetStyles(tiStyles)
155
156 m.bccInput = textinput.New()
157 m.bccInput.Placeholder = t("composer.bcc_placeholder")
158 m.bccInput.Prompt = "> "
159 m.bccInput.CharLimit = 256
160 m.bccInput.SetStyles(tiStyles)
161
162 m.subjectInput = textinput.New()
163 m.subjectInput.Placeholder = t("composer.subject_placeholder")
164 m.subjectInput.SetValue(subject)
165 m.subjectInput.Prompt = "> "
166 m.subjectInput.CharLimit = 256
167 m.subjectInput.SetStyles(tiStyles)
168
169 m.bodyInput = textarea.New()
170 m.bodyInput.Placeholder = t("composer.body_placeholder")
171 m.bodyInput.SetValue(body)
172 m.bodyInput.Prompt = "> "
173 m.bodyInput.SetHeight(10)
174 m.bodyInput.SetStyles(taStyles)
175
176 m.signatureInput = textarea.New()
177 m.signatureInput.Placeholder = t("composer.signature_placeholder")
178 m.signatureInput.Prompt = "> "
179 m.signatureInput.SetHeight(3)
180 m.signatureInput.SetStyles(taStyles)
181 m.updateSignature()
182
183 m.fromInput = textinput.New()
184 m.fromInput.Placeholder = t("composer.from_placeholder")
185 m.fromInput.Prompt = "> "
186 m.fromInput.CharLimit = 256
187 m.fromInput.SetStyles(tiStyles)
188
189 // Start focus on To field (From is selectable but not a text input)
190 m.focusIndex = focusTo
191 m.toInput.Focus()
192
193 return m
194}
195
196func normalizeEmailList(value string) (string, bool) {
197 value = strings.TrimSpace(value)
198 if value == "" {
199 return "", true
200 }
201
202 parts := strings.Split(value, ",")
203 addresses := make([]string, 0, len(parts))
204 for _, part := range parts {
205 part = strings.TrimSpace(part)
206 if part == "" {
207 continue
208 }
209 addr, err := mail.ParseAddress(part)
210 if err != nil || addr.Address == "" {
211 return value, false
212 }
213 addresses = append(addresses, addr.Address)
214 }
215 if len(addresses) == 0 {
216 return "", true
217 }
218 return strings.Join(addresses, ", "), true
219}
220
221func (m *Composer) hasAnyRecipient() bool {
222 return strings.TrimSpace(m.toInput.Value()) != "" ||
223 strings.TrimSpace(m.ccInput.Value()) != "" ||
224 strings.TrimSpace(m.bccInput.Value()) != ""
225}
226
227func (m *Composer) showComposerNotice(message string) tea.Cmd {
228 m.noticeText = message
229 m.showNotice = true
230 return tea.Tick(5*time.Second, func(time.Time) tea.Msg {
231 return hideComposerNoticeMsg{}
232 })
233}
234
235func (m *Composer) hideComposerNotice() {
236 m.showNotice = false
237 m.noticeText = ""
238}
239
240func (m *Composer) validateFromField() bool { //nolint:unparam
241 if !m.isCatchAllAccount() {
242 m.fromError = ""
243 return true
244 }
245 value := strings.TrimSpace(m.fromInput.Value())
246 addr, err := mail.ParseAddress(value)
247 if value == "" || err != nil || addr.Address == "" {
248 m.fromError = t("composer.invalid_email")
249 return false
250 }
251 m.fromError = ""
252 return true
253}
254
255func (m *Composer) validateEmailField(focus int) bool { //nolint:unparam
256 var input *textinput.Model
257 var setError func(string)
258 switch focus {
259 case focusTo:
260 input = &m.toInput
261 setError = func(err string) { m.toError = err }
262 case focusCc:
263 input = &m.ccInput
264 setError = func(err string) { m.ccError = err }
265 case focusBcc:
266 input = &m.bccInput
267 setError = func(err string) { m.bccError = err }
268 default:
269 return true
270 }
271
272 normalized, ok := normalizeEmailList(input.Value())
273 if !ok {
274 setError(t("composer.invalid_email"))
275 return false
276 }
277 input.SetValue(normalized)
278 setError("")
279 return true
280}
281
282func (m *Composer) canSendEmail() bool {
283 m.validateFromField()
284 m.validateEmailField(focusTo)
285 m.validateEmailField(focusCc)
286 m.validateEmailField(focusBcc)
287 return m.fromError == "" && m.toError == "" && m.ccError == "" && m.bccError == ""
288}
289
290// updateSignature updates the signature input based on the current selected account.
291func (m *Composer) updateSignature() {
292 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
293 acc := &m.accounts[m.selectedAccountIdx]
294 if sig, err := config.LoadSignatureForAccount(acc); err == nil && sig != "" {
295 m.signatureInput.SetValue(sig)
296 } else if sig, err := config.LoadSignature(); err == nil && sig != "" {
297 m.signatureInput.SetValue(sig)
298 } else {
299 m.signatureInput.SetValue("")
300 }
301 // Seed the editable From address for catch-all accounts.
302 m.fromInput.SetValue(acc.FormatFromHeader())
303 return
304 }
305
306 if sig, err := config.LoadSignature(); err == nil && sig != "" {
307 m.signatureInput.SetValue(sig)
308 } else {
309 m.signatureInput.SetValue("")
310 }
311}
312
313// NewComposerWithAccounts initializes a composer with multiple account support.
314func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
315 m := NewComposer("", to, subject, body, hideTips)
316 m.accounts = accounts
317
318 // Find the selected account index
319 for i, acc := range accounts {
320 if acc.ID == selectedAccountID {
321 m.selectedAccountIdx = i
322 break
323 }
324 }
325 m.updateSignature()
326
327 return m
328}
329
330// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
331func (m *Composer) ResetConfirmation() {
332 m.confirmingExit = false
333}
334
335// SetFromOverride pre-fills the editable From field (used for catch-all replies).
336func (m *Composer) SetFromOverride(addr string) {
337 m.fromInput.SetValue(addr)
338}
339
340// SetSpellcheckOptions toggles spellcheck features for this composer. Pass
341// disableCheck=true to skip dictionary download/highlighting entirely;
342// disableSuggestions=true keeps inline underlines but suppresses the popup.
343func (m *Composer) SetSpellcheckOptions(disableCheck, disableSuggestions bool) {
344 m.disableSpellcheck = disableCheck
345 m.disableSpellSuggestions = disableSuggestions
346 if disableCheck {
347 m.spellChecker = nil
348 m.spellShow = false
349 m.spellSuggestions = nil
350 }
351}
352
353func (m *Composer) Init() tea.Cmd {
354 cmds := []tea.Cmd{textinput.Blink}
355 if !m.disableSpellcheck {
356 cmds = append(cmds, loadSpellcheckCmd())
357 }
358 return tea.Batch(cmds...)
359}
360
361// loadSpellcheckCmd ensures the default dictionary is downloaded and
362// loaded into a new Checker. Network errors are swallowed: spellcheck is a
363// non-essential overlay, so the composer continues to work normally.
364func loadSpellcheckCmd() tea.Cmd {
365 return func() tea.Msg {
366 lang, err := spellcheck.EnsureDefault()
367 if err != nil {
368 return spellcheckReadyMsg{checker: nil}
369 }
370 c := spellcheck.NewChecker()
371 if err := c.LoadLang(lang); err != nil {
372 return spellcheckReadyMsg{checker: nil}
373 }
374 return spellcheckReadyMsg{checker: c}
375 }
376}
377
378// updateSpellSuggestions inspects the body cursor position and refreshes
379// the suggestion popup. It only fires when the cursor sits at the end of
380// a misspelled word.
381func (m *Composer) updateSpellSuggestions() {
382 m.spellShow = false
383 m.spellSuggestions = nil
384 m.spellWord = ""
385
386 if m.disableSpellcheck || m.disableSpellSuggestions {
387 return
388 }
389 if m.spellChecker == nil || !m.spellChecker.Loaded() {
390 return
391 }
392 if m.focusIndex != focusBody {
393 return
394 }
395
396 value := m.bodyInput.Value()
397 row := m.bodyInput.Line()
398 col := m.bodyInput.Column()
399 lines := strings.Split(value, "\n")
400 if row < 0 || row >= len(lines) {
401 return
402 }
403 line := lines[row]
404 lineRunes := []rune(line)
405 if col > len(lineRunes) {
406 col = len(lineRunes)
407 }
408
409 // Walk back from cursor while we have letters or internal connectors.
410 end := col
411 start := col
412 for start > 0 {
413 r := lineRunes[start-1]
414 if isWordContinuation(r) {
415 start--
416 continue
417 }
418 break
419 }
420 // Trim leading connectors so the word starts on a letter.
421 for start < end && !isLetter(lineRunes[start]) {
422 start++
423 }
424 // Trim trailing connectors so we don't suggest replacements while the
425 // user is still mid-apostrophe.
426 for end > start && !isLetter(lineRunes[end-1]) {
427 end--
428 }
429 if end-start < 2 {
430 return
431 }
432
433 word := string(lineRunes[start:end])
434 if !spellcheck.IsCheckable(word) {
435 return
436 }
437 if m.spellChecker.Check(word) {
438 return
439 }
440
441 suggestions := m.spellChecker.Suggest(word, 5)
442 if len(suggestions) == 0 {
443 return
444 }
445
446 m.spellSuggestions = suggestions
447 m.spellSelected = 0
448 m.spellShow = true
449 m.spellWord = word
450
451 // Byte offsets within the current line, needed by the accept handler.
452 m.spellWordLineStart = len(string(lineRunes[:start]))
453 m.spellWordLineEnd = len(string(lineRunes[:end]))
454 m.spellWordOnLine = row
455
456 // Cache cursor position so a no-op key (e.g. arrow without movement)
457 // doesn't redundantly recompute suggestions.
458 m.spellLastBody = value
459 m.spellLastCursorRow = row
460 m.spellLastCursorCol = col
461}
462
463// acceptSpellSuggestion replaces the misspelled word currently under the
464// cursor with the selected suggestion. It works by sending backspace key
465// events to the textarea (so the textarea's own bookkeeping stays in
466// sync) and then inserting the replacement text.
467func (m *Composer) acceptSpellSuggestion() {
468 if !m.spellShow || len(m.spellSuggestions) == 0 {
469 return
470 }
471 if m.spellSelected < 0 || m.spellSelected >= len(m.spellSuggestions) {
472 return
473 }
474 suggestion := m.spellSuggestions[m.spellSelected]
475
476 // Only replace when the cursor is still at the end of the word we
477 // recorded — otherwise the user moved and the popup is stale.
478 row := m.bodyInput.Line()
479 col := m.bodyInput.Column()
480 lines := strings.Split(m.bodyInput.Value(), "\n")
481 if row != m.spellWordOnLine || row >= len(lines) {
482 m.spellShow = false
483 m.spellSuggestions = nil
484 return
485 }
486 endRunes := len([]rune(lines[row][:m.spellWordLineEnd]))
487 if col != endRunes {
488 m.spellShow = false
489 m.spellSuggestions = nil
490 return
491 }
492
493 wordRuneLen := len([]rune(m.spellWord))
494 for i := 0; i < wordRuneLen; i++ {
495 m.bodyInput, _ = m.bodyInput.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
496 }
497 m.bodyInput.InsertString(suggestion)
498
499 m.spellShow = false
500 m.spellSuggestions = nil
501 m.spellWord = ""
502}
503
504func isWordContinuation(r rune) bool {
505 return isLetter(r) || r == '\'' || r == '’' || r == '-'
506}
507
508func isLetter(r rune) bool {
509 if r < 0x80 {
510 return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
511 }
512 return unicode.IsLetter(r)
513}
514
515func (m *Composer) getFromAddress() string {
516 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
517 return m.accounts[m.selectedAccountIdx].FormatFromHeader()
518 }
519 return ""
520}
521
522func (m *Composer) isCatchAllAccount() bool {
523 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
524 return m.accounts[m.selectedAccountIdx].CatchAll
525 }
526 return false
527}
528
529func (m *Composer) getSelectedAccount() *config.Account {
530 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
531 return &m.accounts[m.selectedAccountIdx]
532 }
533 return nil
534}
535
536func formatAttachmentName(path string) string {
537 name := filepath.Base(path)
538 info, err := os.Stat(path)
539 if err != nil || info.IsDir() {
540 return name
541 }
542 return fmt.Sprintf("%s (%s)", name, tfs(info.Size()))
543}
544
545func (m *Composer) attachmentDisplayName(path string) string {
546 if name, ok := m.attachmentNames[path]; ok {
547 return name
548 }
549 return filepath.Base(path)
550}
551
552func (m *Composer) clampAttachmentCursor() {
553 if len(m.attachmentPaths) == 0 {
554 m.attachmentCursor = 0
555 return
556 }
557 if m.attachmentCursor < 0 {
558 m.attachmentCursor = 0
559 }
560 if m.attachmentCursor >= len(m.attachmentPaths) {
561 m.attachmentCursor = len(m.attachmentPaths) - 1
562 }
563}
564
565func (m *Composer) removeSelectedAttachment() {
566 if len(m.attachmentPaths) == 0 {
567 return
568 }
569
570 m.clampAttachmentCursor()
571 idx := m.attachmentCursor
572 delete(m.attachmentNames, m.attachmentPaths[idx])
573 m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...)
574 m.clampAttachmentCursor()
575}
576
577func suggestionDisplay(s config.Contact, suggestionWidth int) string {
578 display := s.Email
579 if len(s.Addresses) > 0 {
580 display = fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Addresses, ", "))
581 return truncateSuggestionDisplay(display, suggestionWidth)
582 } else if s.Name != "" && s.Name != s.Email {
583 display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
584 }
585 return display
586}
587
588func suggestionDisplayWidth(width int) int {
589 if width > 12 {
590 return width - 6
591 }
592 return 40
593}
594
595func truncateSuggestionDisplay(s string, maxLen int) string {
596 runes := []rune(s)
597 if len(runes) <= maxLen {
598 return s
599 }
600 if maxLen <= 0 {
601 return ""
602 }
603 if maxLen <= 3 {
604 return string(runes[:maxLen])
605 }
606 return string(runes[:maxLen-3]) + "..."
607}
608
609func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
610 var cmds []tea.Cmd
611 var cmd tea.Cmd
612
613 switch msg := msg.(type) {
614 case tea.WindowSizeMsg:
615 m.width = msg.Width
616 m.height = msg.Height
617 inputWidth := msg.Width - 6
618 m.toInput.SetWidth(inputWidth)
619 m.ccInput.SetWidth(inputWidth)
620 m.bccInput.SetWidth(inputWidth)
621 m.subjectInput.SetWidth(inputWidth)
622 m.bodyInput.SetWidth(inputWidth)
623 m.signatureInput.SetWidth(inputWidth)
624 if msg.Height > 0 {
625 // Fixed rows: title, from, to, cc, bcc, subject, sig label,
626 // attachment, smime, button, blank, tip, help = 13
627 const fixedRows = 13
628 available := msg.Height - fixedRows
629 if available < 6 {
630 available = 6
631 }
632 bodyHeight := (available * 55) / 100
633 sigHeight := (available * 15) / 100
634 if bodyHeight < 3 {
635 bodyHeight = 3
636 }
637 if sigHeight < 2 {
638 sigHeight = 2
639 }
640 m.bodyInput.SetHeight(bodyHeight)
641 m.signatureInput.SetHeight(sigHeight)
642 }
643
644 case hideComposerNoticeMsg:
645 m.hideComposerNotice()
646 return m, nil
647
648 case spellcheckReadyMsg:
649 if msg.checker != nil {
650 m.spellChecker = msg.checker
651 m.updateSpellSuggestions()
652 }
653 return m, nil
654
655 case FileSelectedMsg:
656 // Avoid duplicates and add all selected paths
657 for _, newPath := range msg.Paths {
658 exists := false
659 for _, p := range m.attachmentPaths {
660 if p == newPath {
661 exists = true
662 break
663 }
664 }
665 if !exists {
666 m.attachmentPaths = append(m.attachmentPaths, newPath)
667 m.attachmentNames[newPath] = formatAttachmentName(newPath)
668 }
669 }
670 m.clampAttachmentCursor()
671 return m, nil
672
673 case tea.KeyPressMsg:
674 // Handle contact suggestions mode
675 if m.showSuggestions && len(m.suggestions) > 0 {
676 switch msg.String() {
677 case "up", "ctrl+p":
678 if m.selectedSuggestion > 0 {
679 m.selectedSuggestion--
680 }
681 return m, nil
682 case keyDown, "ctrl+n":
683 if m.selectedSuggestion < len(m.suggestions)-1 {
684 m.selectedSuggestion++
685 }
686 return m, nil
687 case "tab", keyEnter:
688 // Select the suggestion
689 selected := m.suggestions[m.selectedSuggestion]
690
691 var newEmail string
692 switch {
693 case len(selected.Addresses) > 0:
694 // Mailing list: emit just the addresses to maintain valid email formatting
695 newEmail = strings.Join(selected.Addresses, ", ")
696 case selected.Name != "" && selected.Name != selected.Email:
697 newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
698 default:
699 newEmail = selected.Email
700 }
701
702 parts := strings.Split(m.toInput.Value(), ",")
703 if len(parts) > 0 {
704 if len(parts) == 1 {
705 parts[0] = newEmail
706 } else {
707 parts[len(parts)-1] = " " + newEmail
708 }
709 } else {
710 parts = []string{newEmail}
711 }
712
713 finalValue := strings.Join(parts, ",")
714 if !strings.HasSuffix(finalValue, ", ") {
715 finalValue += ", "
716 }
717
718 m.toInput.SetValue(finalValue)
719 m.toInput.SetCursor(len(finalValue))
720 m.toError = ""
721 m.lastToValue = m.toInput.Value()
722 m.showSuggestions = false
723 m.suggestions = nil
724 return m, nil
725 case "esc":
726 m.showSuggestions = false
727 m.suggestions = nil
728 return m, nil
729 }
730 // For prev-field key, close suggestions and let it fall through to normal handling
731 if msg.String() == config.Keybinds.Composer.PrevField {
732 m.showSuggestions = false
733 m.suggestions = nil
734 }
735 }
736
737 // Handle plugin prompt overlay
738 if m.showPluginPrompt {
739 switch msg.String() {
740 case keyEnter:
741 value := m.pluginPromptInput.Value()
742 m.showPluginPrompt = false
743 return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
744 case "esc":
745 m.showPluginPrompt = false
746 return m, func() tea.Msg { return PluginPromptCancelMsg{} }
747 default:
748 m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
749 return m, cmd
750 }
751 }
752
753 // Handle account picker mode
754 if m.showAccountPicker {
755 switch msg.String() {
756 case "up", "k":
757 if m.selectedAccountIdx > 0 {
758 m.selectedAccountIdx--
759 m.updateSignature()
760 }
761 case keyDown, "j":
762 if m.selectedAccountIdx < len(m.accounts)-1 {
763 m.selectedAccountIdx++
764 m.updateSignature()
765 }
766 case keyEnter:
767 m.showAccountPicker = false
768 case "esc":
769 m.showAccountPicker = false
770 }
771 return m, nil
772 }
773
774 if m.confirmingExit {
775 switch msg.String() {
776 case "y", "Y":
777 return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
778 case "n", "N", "esc":
779 m.confirmingExit = false
780 return m, nil
781 default:
782 return m, nil
783 }
784 }
785
786 if m.showNotice {
787 switch msg.String() {
788 case keyEnter, "esc", " ":
789 m.hideComposerNotice()
790 }
791 return m, nil
792 }
793
794 // Spellcheck suggestion popup (only while body is focused).
795 if m.focusIndex == focusBody && m.spellShow && len(m.spellSuggestions) > 0 {
796 sk := config.Keybinds.Composer
797 switch msg.String() {
798 case sk.SpellPrev:
799 if m.spellSelected > 0 {
800 m.spellSelected--
801 }
802 return m, nil
803 case sk.SpellNext:
804 if m.spellSelected < len(m.spellSuggestions)-1 {
805 m.spellSelected++
806 }
807 return m, nil
808 case sk.SpellAccept:
809 m.acceptSpellSuggestion()
810 return m, nil
811 case sk.SpellDismiss:
812 m.spellShow = false
813 m.spellSuggestions = nil
814 return m, nil
815 }
816 }
817
818 kb := config.Keybinds
819 attachmentPathSize := len(m.attachmentPaths)
820 if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
821 switch msg.String() {
822 case "up", kb.Global.NavUp:
823 m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize
824 return m, nil
825 case keyDown, kb.Global.NavDown:
826 m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize
827 return m, nil
828 }
829 }
830
831 switch msg.String() {
832 case kb.Global.Quit:
833 return m, tea.Quit
834 case kb.Composer.ExternalEditor:
835 return m, func() tea.Msg { return OpenEditorMsg{} }
836 case kb.Global.Cancel:
837 m.confirmingExit = true
838 return m, nil
839
840 case kb.Composer.NextField, kb.Composer.PrevField:
841 previousFocus := m.focusIndex
842 if msg.String() == kb.Composer.PrevField {
843 m.focusIndex--
844 } else {
845 m.focusIndex++
846 }
847
848 maxFocus := focusSend
849 minFocus := focusFrom
850 // Skip From field if only one non-catch-all account (nothing to switch or edit)
851 if len(m.accounts) <= 1 && !m.isCatchAllAccount() {
852 minFocus = focusTo
853 }
854
855 if m.focusIndex > maxFocus {
856 m.focusIndex = minFocus
857 } else if m.focusIndex < minFocus {
858 m.focusIndex = maxFocus
859 }
860
861 if previousFocus == focusFrom {
862 m.validateFromField()
863 } else if previousFocus != m.focusIndex {
864 m.validateEmailField(previousFocus)
865 }
866
867 m.fromInput.Blur()
868 m.toInput.Blur()
869 m.ccInput.Blur()
870 m.bccInput.Blur()
871 m.subjectInput.Blur()
872 m.bodyInput.Blur()
873 m.signatureInput.Blur()
874 m.spellShow = false
875 m.spellSuggestions = nil
876
877 switch m.focusIndex {
878 case focusFrom:
879 if m.isCatchAllAccount() {
880 cmds = append(cmds, m.fromInput.Focus())
881 }
882 case focusTo:
883 cmds = append(cmds, m.toInput.Focus())
884 case focusCc:
885 cmds = append(cmds, m.ccInput.Focus())
886 case focusBcc:
887 cmds = append(cmds, m.bccInput.Focus())
888 case focusSubject:
889 cmds = append(cmds, m.subjectInput.Focus())
890 case focusBody:
891 cmds = append(cmds, m.bodyInput.Focus())
892 case focusSignature:
893 cmds = append(cmds, m.signatureInput.Focus())
894 }
895 return m, tea.Batch(cmds...)
896
897 case kb.Composer.Delete:
898 if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
899 m.removeSelectedAttachment()
900 return m, nil
901 }
902
903 case keyEnter, " ":
904 switch m.focusIndex {
905 case focusFrom:
906 if msg.String() == keyEnter && len(m.accounts) > 1 {
907 m.showAccountPicker = true
908 return m, nil
909 }
910 if m.isCatchAllAccount() && msg.String() == " " {
911 break
912 }
913 return m, nil
914 case focusAttachment:
915 if msg.String() == keyEnter {
916 return m, func() tea.Msg { return GoToFilePickerMsg{} }
917 }
918 case focusEncryptSMIME:
919 if msg.String() == keyEnter || msg.String() == " " {
920 m.encryptSMIME = !m.encryptSMIME
921 }
922 return m, nil
923
924 case focusSend:
925 if msg.String() == keyEnter {
926 if !m.canSendEmail() {
927 return m, m.showComposerNotice(t("composer.invalid_email_fields"))
928 }
929 if !m.hasAnyRecipient() {
930 return m, m.showComposerNotice(t("composer.recipient_required"))
931 }
932 acc := m.getSelectedAccount()
933 accountID := ""
934 if acc != nil {
935 accountID = acc.ID
936 }
937 fromOverride := ""
938 if m.isCatchAllAccount() {
939 fromOverride = m.fromInput.Value()
940 }
941 return m, func() tea.Msg {
942 return SendEmailMsg{
943 To: m.toInput.Value(),
944 Cc: m.ccInput.Value(),
945 Bcc: m.bccInput.Value(),
946 Subject: m.subjectInput.Value(),
947 Body: m.bodyInput.Value(),
948 AttachmentPaths: m.attachmentPaths,
949 AccountID: accountID,
950 FromOverride: fromOverride,
951 QuotedText: m.quotedText,
952 InReplyTo: m.inReplyTo,
953 References: m.references,
954 Signature: m.signatureInput.Value(),
955 SignSMIME: acc != nil && acc.SMIMESignByDefault,
956 EncryptSMIME: m.encryptSMIME,
957 SignPGP: acc != nil && acc.PGPSignByDefault,
958 }
959 }
960 }
961 }
962 }
963 }
964
965 switch m.focusIndex {
966 case focusFrom:
967 if m.isCatchAllAccount() {
968 previousFromValue := m.fromInput.Value()
969 m.fromInput, cmd = m.fromInput.Update(msg)
970 cmds = append(cmds, cmd)
971 if m.fromInput.Value() != previousFromValue {
972 m.fromError = ""
973 }
974 }
975 case focusTo:
976 previousToValue := m.toInput.Value()
977 m.toInput, cmd = m.toInput.Update(msg)
978 cmds = append(cmds, cmd)
979
980 // Check if To field value changed and update suggestions
981 currentValue := m.toInput.Value()
982 if currentValue != m.lastToValue {
983 if currentValue != previousToValue {
984 m.toError = ""
985 }
986 m.lastToValue = currentValue
987
988 // Extract the last comma-separated part for searching
989 parts := strings.Split(currentValue, ",")
990 lastPart := strings.TrimSpace(parts[len(parts)-1])
991
992 if len(lastPart) >= 2 {
993 m.suggestions = config.SearchContactsForAccount(lastPart, m.GetSelectedAccountID())
994 m.showSuggestions = len(m.suggestions) > 0
995 m.selectedSuggestion = 0
996 } else {
997 m.showSuggestions = false
998 m.suggestions = nil
999 }
1000 }
1001 case focusCc:
1002 previousCcValue := m.ccInput.Value()
1003 m.ccInput, cmd = m.ccInput.Update(msg)
1004 cmds = append(cmds, cmd)
1005 if m.ccInput.Value() != previousCcValue {
1006 m.ccError = ""
1007 }
1008 case focusBcc:
1009 previousBccValue := m.bccInput.Value()
1010 m.bccInput, cmd = m.bccInput.Update(msg)
1011 cmds = append(cmds, cmd)
1012 if m.bccInput.Value() != previousBccValue {
1013 m.bccError = ""
1014 }
1015 case focusSubject:
1016 m.subjectInput, cmd = m.subjectInput.Update(msg)
1017 cmds = append(cmds, cmd)
1018 case focusBody:
1019 prevBody := m.bodyInput.Value()
1020 prevRow := m.bodyInput.Line()
1021 prevCol := m.bodyInput.Column()
1022 m.bodyInput, cmd = m.bodyInput.Update(msg)
1023 cmds = append(cmds, cmd)
1024 // Only recompute suggestions when the body state actually changes.
1025 // Cursor-blink ticks otherwise reset spellSelected to 0 every blink.
1026 if m.bodyInput.Value() != prevBody ||
1027 m.bodyInput.Line() != prevRow ||
1028 m.bodyInput.Column() != prevCol {
1029 m.updateSpellSuggestions()
1030 }
1031 case focusSignature:
1032 m.signatureInput, cmd = m.signatureInput.Update(msg)
1033 cmds = append(cmds, cmd)
1034 }
1035
1036 return m, tea.Batch(cmds...)
1037}
1038
1039func (m *Composer) View() tea.View { //nolint:gocyclo
1040 var composerView strings.Builder
1041 var button string
1042 ck := config.Keybinds.Composer
1043
1044 if m.focusIndex == focusSend {
1045 button = focusedStyle.Render("[ " + t("composer.send") + " ]")
1046 } else {
1047 button = blurredStyle.Render("[ " + t("composer.send") + " ]")
1048 }
1049
1050 // From field with account selector
1051 fromAddr := m.getFromAddress()
1052 var fromField string
1053 if m.isCatchAllAccount() { //nolint:gocritic
1054 fromAddrView := m.fromInput.View()
1055 if len(m.accounts) > 1 {
1056 if m.focusIndex == focusFrom {
1057 fromField = focusedStyle.Render(fmt.Sprintf("> %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.enter_to_switch")+"]")
1058 } else {
1059 fromField = blurredStyle.Render(fmt.Sprintf(" %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.switchable")+"]")
1060 }
1061 } else {
1062 fromField = " " + t("composer.from") + " " + fromAddrView
1063 }
1064 if m.fromError != "" {
1065 fromField += "\n" + composerErrorStyle.Render(m.fromError)
1066 }
1067 } else if len(m.accounts) > 1 {
1068 if m.focusIndex == focusFrom {
1069 fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
1070 } else {
1071 fromField = blurredStyle.Render(fmt.Sprintf(" %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
1072 }
1073 } else if fromAddr != "" {
1074 fromField = " " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
1075 } else {
1076 fromField = blurredStyle.Render(fmt.Sprintf(" %s (%s)", t("composer.from"), t("composer.no_account")))
1077 }
1078
1079 var attachmentField string
1080 if len(m.attachmentPaths) == 0 {
1081 attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
1082 if m.focusIndex == focusAttachment {
1083 attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
1084 } else {
1085 attachmentField = blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.attachments"), attachmentText))
1086 }
1087 } else {
1088 var b strings.Builder
1089 headerPrefix := " "
1090 headerStyle := blurredStyle
1091 if m.focusIndex == focusAttachment {
1092 headerPrefix = "> "
1093 headerStyle = focusedStyle
1094 }
1095 b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths))))
1096 for i, p := range m.attachmentPaths {
1097 cursor := " "
1098 style := blurredStyle
1099 if m.focusIndex == focusAttachment && i == m.attachmentCursor {
1100 cursor = " > "
1101 style = focusedStyle
1102 }
1103 b.WriteString("\n")
1104 b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p))))
1105 }
1106 attachmentField = b.String()
1107 }
1108
1109 encToggle := "[ ]"
1110 if m.encryptSMIME {
1111 encToggle = "[x]"
1112 }
1113 encField := blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.encrypt_smime"), encToggle))
1114 if m.focusIndex == focusEncryptSMIME {
1115 encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
1116 }
1117
1118 // Build To field with suggestions
1119 toFieldView := m.toInput.View()
1120 if m.toError != "" {
1121 toFieldView += "\n" + composerErrorStyle.Render(m.toError)
1122 }
1123 if m.showSuggestions && len(m.suggestions) > 0 {
1124 var suggestionsBuilder strings.Builder
1125 suggestionWidth := suggestionDisplayWidth(m.width)
1126 for i, s := range m.suggestions {
1127 display := suggestionDisplay(s, suggestionWidth)
1128 if i == m.selectedSuggestion {
1129 suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
1130 } else {
1131 suggestionsBuilder.WriteString(suggestionStyle.Render(" "+display) + "\n")
1132 }
1133 }
1134 toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
1135 }
1136
1137 ccFieldView := m.ccInput.View()
1138 if m.ccError != "" {
1139 ccFieldView += "\n" + composerErrorStyle.Render(m.ccError)
1140 }
1141
1142 bccFieldView := m.bccInput.View()
1143 if m.bccError != "" {
1144 bccFieldView += "\n" + composerErrorStyle.Render(m.bccError)
1145 }
1146
1147 // Signature field label
1148 var signatureLabel string
1149 if m.focusIndex == focusSignature {
1150 signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
1151 } else {
1152 signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
1153 }
1154
1155 tip := ""
1156 switch m.focusIndex {
1157 case focusFrom:
1158 tip = "Select the account to send from."
1159 case focusTo:
1160 tip = "Enter recipient email addresses."
1161 case focusCc:
1162 tip = "Carbon copy recipients."
1163 case focusBcc:
1164 tip = "Blind carbon copy recipients."
1165 case focusSubject:
1166 tip = "The subject line of your email."
1167 case focusBody:
1168 if m.spellShow && len(m.spellSuggestions) > 0 {
1169 sk := config.Keybinds.Composer
1170 tip = fmt.Sprintf("Spelling: %s accept • %s/%s navigate • %s dismiss",
1171 sk.SpellAccept, sk.SpellNext, sk.SpellPrev, sk.SpellDismiss)
1172 } else {
1173 tip = "The main content of your email. Markdown and HTML are supported."
1174 }
1175 case focusSignature:
1176 tip = "Your email signature. This will be appended to the end of the email."
1177 case focusAttachment:
1178 tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete)
1179 case focusEncryptSMIME:
1180 tip = "Press Space or Enter to toggle S/MIME encryption on or off."
1181 case focusSend:
1182 tip = "Press Enter to send the email."
1183 }
1184
1185 bodyView := m.bodyInput.View()
1186 if !m.disableSpellcheck && m.spellChecker != nil && m.spellChecker.Loaded() {
1187 bodyView = spellcheck.Highlight(bodyView, m.spellChecker, -1)
1188 }
1189
1190 composerViewElements := []string{
1191 t("composer.title"),
1192 fromField,
1193 toFieldView,
1194 ccFieldView,
1195 bccFieldView,
1196 m.subjectInput.View(),
1197 bodyView,
1198 signatureLabel,
1199 m.signatureInput.View(),
1200 attachmentStyle.Render(attachmentField),
1201 }
1202 if len(m.attachmentPaths) > 0 {
1203 composerViewElements = append(composerViewElements, "")
1204 }
1205 composerViewElements = append(composerViewElements,
1206 smimeToggleStyle.Render(encField),
1207 button,
1208 "",
1209 )
1210
1211 if !m.hideTips && tip != "" {
1212 composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
1213 }
1214
1215 mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
1216 helpText := t("composer.help")
1217 for _, pk := range m.pluginKeyBindings {
1218 helpText += " • " + pk.Key + ": " + pk.Description
1219 }
1220 if m.pluginStatus != "" {
1221 helpText += " • " + m.pluginStatus
1222 }
1223 helpView := helpStyle.Render(helpText)
1224
1225 if m.height > 0 {
1226 currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
1227 gap := m.height - currentHeight
1228 if gap >= 0 {
1229 mainContent += strings.Repeat("\n", gap+1)
1230 } else {
1231 mainContent += "\n"
1232 }
1233 } else {
1234 mainContent += "\n\n"
1235 }
1236
1237 composerView.WriteString(mainContent)
1238 composerView.WriteString(helpView)
1239
1240 // Plugin prompt overlay
1241 if m.showPluginPrompt {
1242 dialog := DialogBoxStyle.Render(
1243 lipgloss.JoinVertical(lipgloss.Left,
1244 m.pluginPromptPlaceholder,
1245 "",
1246 m.pluginPromptInput.View(),
1247 "",
1248 HelpStyle.Render("enter: submit • esc: cancel"),
1249 ),
1250 )
1251 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1252 }
1253
1254 // Account picker overlay
1255 if m.showAccountPicker {
1256 var accountList strings.Builder
1257 accountList.WriteString("Select Account:\n\n")
1258 for i, acc := range m.accounts {
1259 display := acc.GetSendAsEmail()
1260 if acc.Name != "" {
1261 display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
1262 }
1263 if i == m.selectedAccountIdx {
1264 accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
1265 } else {
1266 accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display)))
1267 }
1268 accountList.WriteString("\n")
1269 }
1270 accountList.WriteString("\n")
1271 accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
1272
1273 dialog := DialogBoxStyle.Render(accountList.String())
1274 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1275 }
1276
1277 if m.confirmingExit {
1278 dialog := DialogBoxStyle.Render(
1279 lipgloss.JoinVertical(lipgloss.Center,
1280 t("composer.exit_confirm"),
1281 HelpStyle.Render("\n(y/n)"),
1282 ),
1283 )
1284 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1285 }
1286
1287 if m.showNotice {
1288 dialog := DialogBoxStyle.Render(
1289 lipgloss.JoinVertical(lipgloss.Center,
1290 dangerStyle.Render(m.noticeText),
1291 HelpStyle.Render("\nenter/esc: close"),
1292 ),
1293 )
1294 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1295 }
1296
1297 out := composerView.String()
1298 if m.spellShow && len(m.spellSuggestions) > 0 && m.focusIndex == focusBody {
1299 out = m.overlaySpellPopup(out, composerViewElements)
1300 }
1301 return tea.NewView(out)
1302}
1303
1304// overlaySpellPopup floats the suggestion box at the body cursor position
1305// in the rendered composer view. It returns the view unchanged when the
1306// cursor can't be located.
1307func (m *Composer) overlaySpellPopup(view string, elementsBeforeBody []string) string {
1308 // Body is the 7th element (index 6) of composerViewElements: title,
1309 // from, to, cc, bcc, subject, body, ...
1310 const bodyIdx = 6
1311 if bodyIdx > len(elementsBeforeBody) {
1312 return view
1313 }
1314 bodyStartRow := 0
1315 for i := 0; i < bodyIdx; i++ {
1316 bodyStartRow += lipgloss.Height(elementsBeforeBody[i])
1317 }
1318
1319 li := m.bodyInput.LineInfo()
1320 const promptWidth = 2 // "> "
1321 cursorRow := bodyStartRow + li.RowOffset
1322 cursorCol := li.CharOffset + promptWidth
1323
1324 popup := m.renderSpellPopupLines()
1325 if len(popup) == 0 {
1326 return view
1327 }
1328
1329 // Anchor below cursor. If popup would clip the bottom, raise it above
1330 // the cursor row instead.
1331 anchorRow := cursorRow + 1
1332 if m.height > 0 && anchorRow+len(popup) > m.height-1 && cursorRow-len(popup) >= 0 {
1333 anchorRow = cursorRow - len(popup)
1334 }
1335 anchorCol := cursorCol
1336 popupWidth := lipgloss.Width(popup[0])
1337 if m.width > 0 && anchorCol+popupWidth > m.width {
1338 anchorCol = max(0, m.width-popupWidth)
1339 }
1340
1341 return overlay.Block(view, popup, anchorRow, anchorCol)
1342}
1343
1344// renderSpellPopupLines builds the styled, bordered suggestion box and
1345// returns its rendered lines. Each row carries an "abc" badge to mirror
1346// the language-server look familiar from VSCode.
1347func (m *Composer) renderSpellPopupLines() []string {
1348 if !m.spellShow || len(m.spellSuggestions) == 0 {
1349 return nil
1350 }
1351 maxWidth := 0
1352 for _, s := range m.spellSuggestions {
1353 if w := len(s); w > maxWidth {
1354 maxWidth = w
1355 }
1356 }
1357 rowWidth := maxWidth + 6 // " abc " badge + word + trailing space
1358
1359 iconStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
1360 rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
1361 selStyle := lipgloss.NewStyle().Background(lipgloss.Color("24")).Foreground(lipgloss.Color("231"))
1362
1363 var rows []string
1364 for i, s := range m.spellSuggestions {
1365 text := " " + iconStyle.Render("abc") + " " + s
1366 pad := rowWidth - lipgloss.Width(text)
1367 if pad < 0 {
1368 pad = 0
1369 }
1370 text += strings.Repeat(" ", pad)
1371 if i == m.spellSelected {
1372 rows = append(rows, selStyle.Render(text))
1373 } else {
1374 rows = append(rows, rowStyle.Render(text))
1375 }
1376 }
1377 box := suggestionBoxStyle.Render(strings.Join(rows, "\n"))
1378 return strings.Split(box, "\n")
1379}
1380
1381// SetAccounts sets the available accounts for sending.
1382func (m *Composer) SetAccounts(accounts []config.Account) {
1383 m.accounts = accounts
1384 if m.selectedAccountIdx >= len(accounts) {
1385 m.selectedAccountIdx = 0
1386 }
1387 m.updateSignature()
1388}
1389
1390// SetSelectedAccount sets the selected account by ID.
1391func (m *Composer) SetSelectedAccount(accountID string) {
1392 for i, acc := range m.accounts {
1393 if acc.ID == accountID {
1394 m.selectedAccountIdx = i
1395 m.updateSignature()
1396 return
1397 }
1398 }
1399}
1400
1401// GetSelectedAccountID returns the ID of the currently selected account.
1402func (m *Composer) GetSelectedAccountID() string {
1403 if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
1404 return m.accounts[m.selectedAccountIdx].ID
1405 }
1406 return ""
1407}
1408
1409// GetDraftID returns the draft ID for this composer.
1410func (m *Composer) GetDraftID() string {
1411 return m.draftID
1412}
1413
1414// SetDraftID sets the draft ID (for loading existing drafts).
1415func (m *Composer) SetDraftID(id string) {
1416 m.draftID = id
1417}
1418
1419// GetTo returns the current To field value.
1420func (m *Composer) GetTo() string {
1421 return m.toInput.Value()
1422}
1423
1424// SetTo updates the To field with new content.
1425func (m *Composer) SetTo(to string) {
1426 m.toInput.SetValue(to)
1427}
1428
1429// GetCc returns the current Cc field value.
1430func (m *Composer) GetCc() string {
1431 return m.ccInput.Value()
1432}
1433
1434// SetCc updates the Cc field with new content.
1435func (m *Composer) SetCc(cc string) {
1436 m.ccInput.SetValue(cc)
1437}
1438
1439// GetBcc returns the current Bcc field value.
1440func (m *Composer) GetBcc() string {
1441 return m.bccInput.Value()
1442}
1443
1444// SetBcc updates the Bcc field with new content.
1445func (m *Composer) SetBcc(bcc string) {
1446 m.bccInput.SetValue(bcc)
1447}
1448
1449// GetSubject returns the current Subject field value.
1450func (m *Composer) GetSubject() string {
1451 return m.subjectInput.Value()
1452}
1453
1454// SetSubject updates the Subject field with new content.
1455func (m *Composer) SetSubject(subject string) {
1456 m.subjectInput.SetValue(subject)
1457}
1458
1459// GetBody returns the current Body field value.
1460func (m *Composer) GetBody() string {
1461 return m.bodyInput.Value()
1462}
1463
1464// SetBody updates the Body field with new content.
1465func (m *Composer) SetBody(body string) {
1466 m.bodyInput.SetValue(body)
1467}
1468
1469// GetAttachmentPaths returns the current attachment paths.
1470func (m *Composer) GetAttachmentPaths() []string {
1471 return m.attachmentPaths
1472}
1473
1474// GetSignature returns the current signature value.
1475func (m *Composer) GetSignature() string {
1476 return m.signatureInput.Value()
1477}
1478
1479// SetReplyContext sets the reply context for the draft.
1480func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
1481 m.inReplyTo = inReplyTo
1482 m.references = references
1483}
1484
1485// SetQuotedText sets the hidden quoted text that will be appended when sending.
1486func (m *Composer) SetQuotedText(text string) {
1487 m.quotedText = text
1488}
1489
1490// GetQuotedText returns the hidden quoted text.
1491func (m *Composer) GetQuotedText() string {
1492 return m.quotedText
1493}
1494
1495// GetInReplyTo returns the In-Reply-To header value.
1496func (m *Composer) GetInReplyTo() string {
1497 return m.inReplyTo
1498}
1499
1500// GetReferences returns the References header values.
1501func (m *Composer) GetReferences() []string {
1502 return m.references
1503}
1504
1505// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
1506func (m *Composer) SetPluginStatus(status string) {
1507 m.pluginStatus = status
1508}
1509
1510// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1511func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1512 m.pluginKeyBindings = bindings
1513}
1514
1515// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
1516func (m *Composer) ShowPluginPrompt(placeholder string) {
1517 m.pluginPromptPlaceholder = placeholder
1518 m.pluginPromptInput = textinput.New()
1519 m.pluginPromptInput.Placeholder = placeholder
1520 m.pluginPromptInput.Prompt = "> "
1521 m.pluginPromptInput.CharLimit = 256
1522 m.pluginPromptInput.Focus()
1523 m.showPluginPrompt = true
1524}
1525
1526// HidePluginPrompt deactivates the plugin prompt overlay.
1527func (m *Composer) HidePluginPrompt() {
1528 m.showPluginPrompt = false
1529}
1530
1531// HasContent reports whether the composer holds anything worth persisting.
1532// It is used to avoid saving empty drafts when the user quits the composer.
1533func (m *Composer) HasContent() bool {
1534 return m.hasAnyRecipient() ||
1535 strings.TrimSpace(m.subjectInput.Value()) != "" ||
1536 strings.TrimSpace(m.bodyInput.Value()) != "" ||
1537 len(m.attachmentPaths) > 0
1538}
1539
1540// ToDraft converts the composer state to a Draft for saving.
1541func (m *Composer) ToDraft() config.Draft {
1542 return config.Draft{
1543 ID: m.draftID,
1544 To: m.toInput.Value(),
1545 Cc: m.ccInput.Value(),
1546 Bcc: m.bccInput.Value(),
1547 Subject: m.subjectInput.Value(),
1548 Body: m.bodyInput.Value(),
1549 AttachmentPaths: m.attachmentPaths,
1550 AccountID: m.GetSelectedAccountID(),
1551 FromOverride: m.fromInput.Value(),
1552 InReplyTo: m.inReplyTo,
1553 References: m.references,
1554 QuotedText: m.quotedText,
1555 }
1556}
1557
1558// NewComposerFromDraft creates a composer from an existing draft.
1559func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
1560 m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
1561 m.ccInput.SetValue(draft.Cc)
1562 m.bccInput.SetValue(draft.Bcc)
1563 m.draftID = draft.ID
1564 m.attachmentPaths = draft.AttachmentPaths
1565 m.attachmentNames = make(map[string]string, len(m.attachmentPaths))
1566 for _, path := range m.attachmentPaths {
1567 m.attachmentNames[path] = formatAttachmentName(path)
1568 }
1569 m.clampAttachmentCursor()
1570 if m.isCatchAllAccount() && draft.FromOverride != "" {
1571 m.fromInput.SetValue(draft.FromOverride)
1572 }
1573 m.inReplyTo = draft.InReplyTo
1574 m.references = draft.References
1575 m.quotedText = draft.QuotedText
1576 return m
1577}