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