feat: BCC and CC (#143)(#146)

Drew Smirnoff created

Change summary

config/cache.go      |   2 
go.mod               |   2 
go.sum               |   6 --
main.go              |  28 ++++++++--
sender/sender.go     |  15 ++++-
tui/composer.go      |  38 ++++++++++++++
tui/composer_test.go | 121 +++++++++++----------------------------------
tui/messages.go      |   2 
8 files changed, 107 insertions(+), 107 deletions(-)

Detailed changes

config/cache.go 🔗

@@ -231,6 +231,8 @@ func SearchContacts(query string) []Contact {
 type Draft struct {
 	ID             string    `json:"id"`
 	To             string    `json:"to"`
+	Cc             string    `json:"cc,omitempty"`
+	Bcc            string    `json:"bcc,omitempty"`
 	Subject        string    `json:"subject"`
 	Body           string    `json:"body"`
 	AttachmentPath string    `json:"attachment_path,omitempty"`

go.mod 🔗

@@ -1,6 +1,6 @@
 module github.com/floatpane/matcha
 
-go 1.26.0
+go 1.24.2
 
 require (
 	github.com/PuerkitoBio/goquery v1.11.0

go.sum 🔗

@@ -10,8 +10,6 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
-github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
-github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -20,8 +18,6 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco
 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
-github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
-github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
 github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
 github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
 github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
@@ -140,8 +136,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
-golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
 golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
 golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

main.go 🔗

@@ -842,6 +842,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
+func (m *mainModel) View() string {
+	return m.current.View()
+}
+
 func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
 	switch mailbox {
 	case tui.MailboxSent:
@@ -1070,10 +1074,6 @@ func (m *mainModel) removeEmailByMailbox(uid uint32, accountID string, mailbox t
 	}
 }
 
-func (m *mainModel) View() string {
-	return m.current.View()
-}
-
 func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
 	var allEmails []fetcher.Email
 	for _, emails := range emailsByAccount {
@@ -1300,13 +1300,29 @@ func markdownToHTML(md []byte) []byte {
 	return buf.Bytes()
 }
 
+func splitEmails(s string) []string {
+	if s == "" {
+		return nil
+	}
+	parts := strings.Split(s, ",")
+	var res []string
+	for _, p := range parts {
+		if trimmed := strings.TrimSpace(p); trimmed != "" {
+			res = append(res, trimmed)
+		}
+	}
+	return res
+}
+
 func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
 	return func() tea.Msg {
 		if account == nil {
 			return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
 		}
 
-		recipients := []string{msg.To}
+		recipients := splitEmails(msg.To)
+		cc := splitEmails(msg.Cc)
+		bcc := splitEmails(msg.Bcc)
 		body := msg.Body
 		// Append signature if present
 		if msg.Signature != "" {
@@ -1346,7 +1362,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
 			}
 		}
 
-		err := sender.SendEmail(account, recipients, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
+		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
 		if err != nil {
 			log.Printf("Failed to send email: %v", err)
 			return tui.EmailResultMsg{Err: err}

sender/sender.go 🔗

@@ -27,7 +27,7 @@ func generateMessageID(from string) string {
 }
 
 // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
-func SendEmail(account *config.Account, to []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error {
+func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error {
 	smtpServer := account.GetSMTPServer()
 	smtpPort := account.GetSMTPPort()
 
@@ -49,13 +49,17 @@ func SendEmail(account *config.Account, to []string, subject, plainBody, htmlBod
 	// Set top-level headers for a mixed message type to support content and attachments
 	headers := map[string]string{
 		"From":         fromHeader,
-		"To":           to[0],
+		"To":           strings.Join(to, ", "),
 		"Subject":      subject,
 		"Date":         time.Now().Format(time.RFC1123Z),
 		"Message-ID":   generateMessageID(account.Email),
 		"Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(),
 	}
 
+	if len(cc) > 0 {
+		headers["Cc"] = strings.Join(cc, ", ")
+	}
+
 	if inReplyTo != "" {
 		headers["In-Reply-To"] = inReplyTo
 		if len(references) > 0 {
@@ -156,8 +160,13 @@ func SendEmail(account *config.Account, to []string, subject, plainBody, htmlBod
 
 	mainWriter.Close() // Finish the main message
 
+	// Combine all recipients for the envelope
+	allRecipients := append([]string{}, to...)
+	allRecipients = append(allRecipients, cc...)
+	allRecipients = append(allRecipients, bcc...)
+
 	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
-	return smtp.SendMail(addr, auth, account.Email, to, msg.Bytes())
+	return smtp.SendMail(addr, auth, account.Email, allRecipients, msg.Bytes())
 }
 
 // wrapBase64 wraps base64-encoded data at 76 characters per line as required by MIME.

tui/composer.go 🔗

@@ -35,6 +35,8 @@ var (
 const (
 	focusFrom = iota
 	focusTo
+	focusCc
+	focusBcc
 	focusSubject
 	focusBody
 	focusSignature
@@ -46,6 +48,8 @@ const (
 type Composer struct {
 	focusIndex     int
 	toInput        textinput.Model
+	ccInput        textinput.Model
+	bccInput       textinput.Model
 	subjectInput   textinput.Model
 	bodyInput      textarea.Model
 	signatureInput textarea.Model
@@ -89,6 +93,18 @@ func NewComposer(from, to, subject, body string) *Composer {
 	m.toInput.Prompt = "> "
 	m.toInput.CharLimit = 256
 
+	m.ccInput = textinput.New()
+	m.ccInput.Cursor.Style = cursorStyle
+	m.ccInput.Placeholder = "Cc"
+	m.ccInput.Prompt = "> "
+	m.ccInput.CharLimit = 256
+
+	m.bccInput = textinput.New()
+	m.bccInput.Cursor.Style = cursorStyle
+	m.bccInput.Placeholder = "Bcc"
+	m.bccInput.Prompt = "> "
+	m.bccInput.CharLimit = 256
+
 	m.subjectInput = textinput.New()
 	m.subjectInput.Cursor.Style = cursorStyle
 	m.subjectInput.Placeholder = "Subject"
@@ -174,6 +190,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.height = msg.Height
 		inputWidth := msg.Width - 6
 		m.toInput.Width = inputWidth
+		m.ccInput.Width = inputWidth
+		m.bccInput.Width = inputWidth
 		m.subjectInput.Width = inputWidth
 		m.bodyInput.SetWidth(inputWidth)
 		m.signatureInput.SetWidth(inputWidth)
@@ -283,6 +301,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 
 			m.toInput.Blur()
+			m.ccInput.Blur()
+			m.bccInput.Blur()
 			m.subjectInput.Blur()
 			m.bodyInput.Blur()
 			m.signatureInput.Blur()
@@ -290,6 +310,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			switch m.focusIndex {
 			case focusTo:
 				cmds = append(cmds, m.toInput.Focus())
+			case focusCc:
+				cmds = append(cmds, m.ccInput.Focus())
+			case focusBcc:
+				cmds = append(cmds, m.bccInput.Focus())
 			case focusSubject:
 				cmds = append(cmds, m.subjectInput.Focus())
 			case focusBody:
@@ -318,6 +342,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, func() tea.Msg {
 					return SendEmailMsg{
 						To:             m.toInput.Value(),
+						Cc:             m.ccInput.Value(),
+						Bcc:            m.bccInput.Value(),
 						Subject:        m.subjectInput.Value(),
 						Body:           m.bodyInput.Value(),
 						AttachmentPath: m.attachmentPath,
@@ -350,6 +376,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.suggestions = nil
 			}
 		}
+	case focusCc:
+		m.ccInput, cmd = m.ccInput.Update(msg)
+		cmds = append(cmds, cmd)
+	case focusBcc:
+		m.bccInput, cmd = m.bccInput.Update(msg)
+		cmds = append(cmds, cmd)
 	case focusSubject:
 		m.subjectInput, cmd = m.subjectInput.Update(msg)
 		cmds = append(cmds, cmd)
@@ -431,6 +463,8 @@ func (m *Composer) View() string {
 		"Compose New Email",
 		fromField,
 		toFieldView,
+		m.ccInput.View(),
+		m.bccInput.View(),
 		m.subjectInput.View(),
 		m.bodyInput.View(),
 		signatureLabel,
@@ -568,6 +602,8 @@ func (m *Composer) ToDraft() config.Draft {
 	return config.Draft{
 		ID:             m.draftID,
 		To:             m.toInput.Value(),
+		Cc:             m.ccInput.Value(),
+		Bcc:            m.bccInput.Value(),
 		Subject:        m.subjectInput.Value(),
 		Body:           m.bodyInput.Value(),
 		AttachmentPath: m.attachmentPath,
@@ -581,6 +617,8 @@ func (m *Composer) ToDraft() config.Draft {
 // NewComposerFromDraft creates a composer from an existing draft.
 func NewComposerFromDraft(draft config.Draft, accounts []config.Account) *Composer {
 	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body)
+	m.ccInput.SetValue(draft.Cc)
+	m.bccInput.SetValue(draft.Bcc)
 	m.draftID = draft.ID
 	m.attachmentPath = draft.AttachmentPath
 	m.inReplyTo = draft.InReplyTo

tui/composer_test.go 🔗

@@ -22,39 +22,53 @@ func TestComposerUpdate(t *testing.T) {
 			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex)
 		}
 
-		// Simulate pressing Tab to move to the 'Subject' field.
+		// Simulate pressing Tab to move to the 'Cc' field.
 		model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
+		if composer.focusIndex != focusCc {
+			t.Errorf("After one Tab, focusIndex should be %d (focusCc), got %d", focusCc, composer.focusIndex)
+		}
+
+		// Simulate pressing Tab to move to the 'Bcc' field.
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != focusBcc {
+			t.Errorf("After two Tabs, focusIndex should be %d (focusBcc), got %d", focusBcc, composer.focusIndex)
+		}
+
+		// Simulate pressing Tab to move to the 'Subject' field.
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
 		if composer.focusIndex != focusSubject {
-			t.Errorf("After one Tab, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
+			t.Errorf("After three Tabs, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Body' field.
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusBody {
-			t.Errorf("After two Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
+			t.Errorf("After four Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Signature' field.
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusSignature {
-			t.Errorf("After three Tabs, focusIndex should be %d (focusSignature), got %d", focusSignature, composer.focusIndex)
+			t.Errorf("After five Tabs, focusIndex should be %d (focusSignature), got %d", focusSignature, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Attachment' field.
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusAttachment {
-			t.Errorf("After four Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
+			t.Errorf("After six Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Send' button.
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusSend {
-			t.Errorf("After five Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
+			t.Errorf("After seven Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
 		}
 
 		// Simulate one more Tab to wrap around.
@@ -62,7 +76,7 @@ func TestComposerUpdate(t *testing.T) {
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusTo {
-			t.Errorf("After six Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
+			t.Errorf("After eight Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
 		}
 	})
 
@@ -175,8 +189,12 @@ func TestComposerUpdate(t *testing.T) {
 			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
 		}
 
-		// Tab through all fields: To -> Subject -> Body -> Signature -> Attachment -> Send -> From (wrap)
-		model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // To -> Subject
+		// Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> Send -> From (wrap)
+		model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // To -> Cc
+		multiComposer = model.(*Composer)
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Cc -> Bcc
+		multiComposer = model.(*Composer)
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Bcc -> Subject
 		multiComposer = model.(*Composer)
 		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Subject -> Body
 		multiComposer = model.(*Composer)
@@ -188,91 +206,12 @@ func TestComposerUpdate(t *testing.T) {
 		multiComposer = model.(*Composer)
 		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Send -> From (wrap)
 		multiComposer = model.(*Composer)
-
-		// With multiple accounts, From field should be included in tab order
-		if multiComposer.focusIndex != focusFrom {
-			t.Errorf("After six Tabs with multi-account, focusIndex should wrap to %d (focusFrom), got %d", focusFrom, multiComposer.focusIndex)
-		}
-
-		// One more Tab should go to To
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // From -> To
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // From -> To (wrap)
 		multiComposer = model.(*Composer)
-		if multiComposer.focusIndex != focusTo {
-			t.Errorf("After Tab from From, focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
-		}
-	})
-
-	t.Run("Shift+Tab backwards navigation", func(t *testing.T) {
-		accounts := []config.Account{
-			{ID: "account-1", Email: "test@example.com"},
-		}
-		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "")
-
-		// Start at focusTo, move forward a couple times
-		if composer.focusIndex != focusTo {
-			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex)
-		}
-
-		// Tab forward to Subject
-		model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab})
-		composer = model.(*Composer)
-		if composer.focusIndex != focusSubject {
-			t.Errorf("After Tab, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
-		}
 
-		// Tab forward to Body
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
-		composer = model.(*Composer)
-		if composer.focusIndex != focusBody {
-			t.Errorf("After second Tab, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
-		}
-
-		// Shift+Tab back to Subject
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
-		composer = model.(*Composer)
-		if composer.focusIndex != focusSubject {
-			t.Errorf("After Shift+Tab, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
-		}
-
-		// Shift+Tab back to To
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
-		composer = model.(*Composer)
-		if composer.focusIndex != focusTo {
-			t.Errorf("After second Shift+Tab, focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex)
-		}
-
-		// Shift+Tab should wrap to Send (skipping From since single account)
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
-		composer = model.(*Composer)
-		if composer.focusIndex != focusSend {
-			t.Errorf("After Shift+Tab from To, focusIndex should wrap to %d (focusSend), got %d", focusSend, composer.focusIndex)
-		}
-	})
-
-	t.Run("Multi-account Shift+Tab includes From", func(t *testing.T) {
-		multiAccounts := []config.Account{
-			{ID: "account-1", Email: "test1@example.com"},
-			{ID: "account-2", Email: "test2@example.com"},
-		}
-		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "")
-
-		// Start at focusTo
+		// With multiple accounts, From field should be included in tab order
 		if multiComposer.focusIndex != focusTo {
-			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
-		}
-
-		// Shift+Tab should go to From (since multi-account)
-		model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
-		multiComposer = model.(*Composer)
-		if multiComposer.focusIndex != focusFrom {
-			t.Errorf("After Shift+Tab from To with multi-account, focusIndex should be %d (focusFrom), got %d", focusFrom, multiComposer.focusIndex)
-		}
-
-		// Shift+Tab again should wrap to Send
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
-		multiComposer = model.(*Composer)
-		if multiComposer.focusIndex != focusSend {
-			t.Errorf("After Shift+Tab from From, focusIndex should wrap to %d (focusSend), got %d", focusSend, multiComposer.focusIndex)
+			t.Errorf("After nine Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
 		}
 	})
 }

tui/messages.go 🔗

@@ -23,6 +23,8 @@ type ViewEmailMsg struct {
 
 type SendEmailMsg struct {
 	To             string
+	Cc             string // Cc recipient(s)
+	Bcc            string // Bcc recipient(s)
 	Subject        string
 	Body           string
 	AttachmentPath string