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