composer.go

  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}