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