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