From 613bcc5a7c926a96d0eefb0086261bb1e665bc5a Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sat, 23 May 2026 20:39:33 +0400 Subject: [PATCH] fix: lint issues (#1358) Signed-off-by: drew --- backend/jmap/jmap.go | 18 +- backend/maildir/maildir.go | 20 +- backend/maildir/maildir_test.go | 5 +- backend/pop3/pop3.go | 22 +- calendar/calendar.go | 2 +- cli/config.go | 4 +- cli/contacts_export.go | 2 +- cli/install.go | 8 +- cli/integration.go | 30 +-- clib/htmlconv.go | 2 +- clib/imgconv.go | 2 +- clib/macos/appearance.go | 6 +- clib/macos/badge.go | 6 +- clib/macos/contacts.go | 6 +- clib/macos/file_picker.go | 6 +- config/cache.go | 14 +- config/config.go | 38 +-- config/encryption.go | 6 +- config/keybinds.go | 12 +- config/lru.go | 5 +- config/oauth.go | 4 +- config/signature.go | 2 +- daemon/daemon.go | 42 +-- daemon/handler.go | 87 +++--- daemonclient/client.go | 2 +- daemonclient/service.go | 6 +- daemonrpc/protocol.go | 7 +- fetcher/fetcher.go | 169 ++++++------ fetcher/idle.go | 12 +- fetcher/search.go | 2 +- i18n/detector.go | 5 - i18n/interpolator.go | 2 +- i18n/loader.go | 4 +- i18n/message.go | 4 + i18n/template.go | 2 +- i18n/validator.go | 12 +- internal/threading/jwz_test.go | 4 +- main.go | 411 ++++++++--------------------- notify/notify.go | 4 +- pgp/yubikey.go | 54 ++-- pgp/yubikey_test.go | 1 - plugin/api.go | 24 +- plugin/http.go | 6 +- plugin/http_test.go | 6 +- plugin/prompt.go | 2 +- plugin/settings.go | 6 +- plugin/storage.go | 54 ++-- plugins/embed.go | 8 +- screenshots/cmd/inbox_view/main.go | 57 ++-- sender/sender.go | 64 ++--- tests/integration/imap_test.go | 2 +- theme/theme.go | 2 +- tui/choice.go | 7 +- tui/composer.go | 47 ++-- tui/constants.go | 11 + tui/email_view.go | 26 +- tui/filepicker.go | 8 +- tui/folder_inbox.go | 70 +++-- tui/folder_inbox_test.go | 6 +- tui/inbox.go | 67 +++-- tui/login.go | 6 +- tui/mailing_list.go | 27 +- tui/marketplace.go | 12 +- tui/password_prompt.go | 3 +- tui/search.go | 3 +- tui/settings.go | 46 ++-- tui/settings_accounts.go | 4 +- tui/settings_crypto.go | 16 +- tui/settings_encryption.go | 16 +- tui/settings_general.go | 15 +- tui/settings_lists.go | 6 +- tui/settings_plugins.go | 22 +- tui/settings_theme.go | 4 +- tui/signature.go | 4 +- tui/theme.go | 3 - view/html.go | 214 +++------------ 76 files changed, 807 insertions(+), 1117 deletions(-) create mode 100644 tui/constants.go diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index 0f6d0d1dbc3385898d63ef7f71c71f2109ba18ea..85181d84b7ee0cfce569b81157295145237383cf 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -23,6 +23,8 @@ import ( "github.com/floatpane/matcha/config" ) +const jmapMailboxIds = "mailboxIds" + func init() { backend.RegisterBackend("jmap", func(account *config.Account) (backend.Provider, error) { return New(account) @@ -167,7 +169,7 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u }, Properties: []string{ "id", "subject", "from", "to", "replyTo", "receivedAt", - "preview", "keywords", "mailboxIds", "hasAttachment", + "preview", "keywords", jmapMailboxIds, "hasAttachment", "messageId", "inReplyTo", "references", }, }) @@ -220,7 +222,7 @@ func (p *Provider) Search(_ context.Context, folder string, query backend.Search }, Properties: []string{ "id", "subject", "from", "to", "replyTo", "receivedAt", - "preview", "keywords", "mailboxIds", "hasAttachment", + "preview", "keywords", jmapMailboxIds, "hasAttachment", "messageId", }, }) @@ -356,7 +358,7 @@ func (p *Provider) FetchAttachment(_ context.Context, _ string, _ uint32, partID if err != nil { return nil, fmt.Errorf("jmap download: %w", err) } - defer reader.Close() + defer reader.Close() //nolint:errcheck return io.ReadAll(reader) } @@ -419,7 +421,7 @@ func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error { req.Invoke(&email.Set{ Account: p.accountID, Update: map[jmapclient.ID]jmapclient.Patch{ - jmapID: {"mailboxIds": map[jmapclient.ID]bool{trashID: true}}, + jmapID: {jmapMailboxIds: map[jmapclient.ID]bool{trashID: true}}, }, }) _, err = p.client.Do(req) @@ -441,7 +443,7 @@ func (p *Provider) ArchiveEmail(_ context.Context, _ string, uid uint32) error { req.Invoke(&email.Set{ Account: p.accountID, Update: map[jmapclient.ID]jmapclient.Patch{ - jmapID: {"mailboxIds": map[jmapclient.ID]bool{archiveID: true}}, + jmapID: {jmapMailboxIds: map[jmapclient.ID]bool{archiveID: true}}, }, }) _, err = p.client.Do(req) @@ -463,7 +465,7 @@ func (p *Provider) MoveEmail(_ context.Context, uid uint32, _, dstFolder string) req.Invoke(&email.Set{ Account: p.accountID, Update: map[jmapclient.ID]jmapclient.Patch{ - jmapID: {"mailboxIds": map[jmapclient.ID]bool{dstID: true}}, + jmapID: {jmapMailboxIds: map[jmapclient.ID]bool{dstID: true}}, }, }) _, err = p.client.Do(req) @@ -596,7 +598,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro if sentID != "" { subReq.OnSuccessUpdateEmail = map[jmapclient.ID]jmapclient.Patch{ "#sub": { - "mailboxIds": map[jmapclient.ID]bool{sentID: true}, + jmapMailboxIds: map[jmapclient.ID]bool{sentID: true}, "keywords/$draft": nil, }, } @@ -690,7 +692,7 @@ func (p *Provider) lookupJMAPID(uid uint32) (jmapclient.ID, error) { // jmapIDToUID converts a JMAP string ID to a uint32 hash for use as a UID. func jmapIDToUID(id jmapclient.ID) uint32 { h := fnv.New32a() - h.Write([]byte(id)) + h.Write([]byte(id)) //nolint:gosec v := h.Sum32() if v == 0 { v = 1 diff --git a/backend/maildir/maildir.go b/backend/maildir/maildir.go index ee0b49c15e2564c5189dbe7750609294d062fba3..ff6e8a5b3c8c129d8dff55ef1bb990ffca7ef478 100644 --- a/backend/maildir/maildir.go +++ b/backend/maildir/maildir.go @@ -10,6 +10,7 @@ package maildir import ( "context" + "errors" "fmt" "io" "mime" @@ -165,7 +166,7 @@ func (p *Provider) readHeader(msg *emaildir.Message) (backend.Email, error) { if err != nil { return backend.Email{}, err } - defer rc.Close() + defer rc.Close() //nolint:errcheck entity, err := message.Read(rc) if err != nil && entity == nil { @@ -194,7 +195,7 @@ func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) if err != nil { return "", "", nil, fmt.Errorf("maildir open: %w", err) } - defer rc.Close() + defer rc.Close() //nolint:errcheck return parseMessageBody(rc) } @@ -209,7 +210,7 @@ func (p *Provider) FetchAttachment(_ context.Context, folder string, uid uint32, if err != nil { return nil, fmt.Errorf("maildir open: %w", err) } - defer rc.Close() + defer rc.Close() //nolint:errcheck return findAttachmentData(rc, partID) } @@ -348,7 +349,7 @@ func (p *Provider) matchOpen(msg *emaildir.Message) (backend.Email, string, erro if err != nil { return backend.Email{}, "", err } - defer rc.Close() + defer rc.Close() //nolint:errcheck entity, err := message.Read(rc) if err != nil && entity == nil { @@ -552,7 +553,7 @@ func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) for { part, err := mr.NextPart() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { @@ -568,7 +569,8 @@ func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) continue } - if disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")) { + switch { + case disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")): filename := dParams["filename"] if filename == "" { _, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type")) @@ -585,9 +587,9 @@ func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) att.ContentID = strings.Trim(cid, "<>") } attachments = append(attachments, att) - } else if contentType == "text/html" { + case contentType == "text/html": htmlBody = string(data) - } else if contentType == "text/plain" && bodyText == "" { + case contentType == "text/plain" && bodyText == "": bodyText = string(data) } } @@ -608,7 +610,7 @@ func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) { partIdx := 0 for { part, err := mr.NextPart() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/backend/maildir/maildir_test.go b/backend/maildir/maildir_test.go index 0b8c69022fd98a651ee5336c22f5c75fe23e2f99..e05f703f223f82afe575b26dd3cddbaf4d348c48 100644 --- a/backend/maildir/maildir_test.go +++ b/backend/maildir/maildir_test.go @@ -2,6 +2,7 @@ package maildir import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -238,7 +239,7 @@ func TestArchiveEmailRequiresArchiveFolder(t *testing.T) { p := newProvider(t, root) emails, _ := p.FetchEmails(context.Background(), "INBOX", 10, 0) err := p.ArchiveEmail(context.Background(), "INBOX", emails[0].UID) - if err != backend.ErrNotSupported { + if !errors.Is(err, backend.ErrNotSupported) { t.Errorf("want ErrNotSupported, got %v", err) } } @@ -246,7 +247,7 @@ func TestArchiveEmailRequiresArchiveFolder(t *testing.T) { func TestSendEmailNotSupported(t *testing.T) { root := makeMaildir(t) p := newProvider(t, root) - if err := p.SendEmail(context.Background(), &backend.OutgoingEmail{}); err != backend.ErrNotSupported { + if err := p.SendEmail(context.Background(), &backend.OutgoingEmail{}); !errors.Is(err, backend.ErrNotSupported) { t.Errorf("want ErrNotSupported, got %v", err) } } diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index 4ec52040a3e9164c52fa9a8fe0c8d67b7f16c48f..6b861d02bd8aa6757e48c78a5b74d0681f68e40f 100644 --- a/backend/pop3/pop3.go +++ b/backend/pop3/pop3.go @@ -11,6 +11,7 @@ package pop3 import ( "context" + "errors" "fmt" "io" "mime" @@ -78,7 +79,7 @@ func (p *Provider) connect() (*pop3client.Conn, error) { } if err := conn.Auth(p.account.Email, p.account.Password); err != nil { - conn.Quit() + _ = conn.Quit() return nil, fmt.Errorf("pop3 auth: %w", err) } @@ -90,7 +91,7 @@ func (p *Provider) FetchEmails(_ context.Context, _ string, limit, offset uint32 if err != nil { return nil, err } - defer conn.Quit() + defer conn.Quit() //nolint:errcheck // Get message list with UIDs msgs, err := conn.Uidl(0) @@ -139,7 +140,7 @@ func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (stri if err != nil { return "", "", nil, err } - defer conn.Quit() + defer conn.Quit() //nolint:errcheck msgID, err := p.findMessageByUID(conn, uid) if err != nil { @@ -159,7 +160,7 @@ func (p *Provider) FetchAttachment(_ context.Context, _ string, uid uint32, part if err != nil { return nil, err } - defer conn.Quit() + defer conn.Quit() //nolint:errcheck msgID, err := p.findMessageByUID(conn, uid) if err != nil { @@ -212,7 +213,7 @@ func (p *Provider) DeleteEmails(_ context.Context, _ string, uids []uint32) erro messageIDsByUID, err := p.buildMessageIDsByUID(conn) if err != nil { - conn.Quit() + _ = conn.Quit() return err } @@ -415,7 +416,7 @@ func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) for { part, err := mr.NextPart() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { @@ -431,7 +432,8 @@ func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) continue } - if disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")) { + switch { + case disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")): filename := dParams["filename"] if filename == "" { _, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type")) @@ -448,9 +450,9 @@ func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) att.ContentID = strings.Trim(cid, "<>") } attachments = append(attachments, att) - } else if contentType == "text/html" { + case contentType == "text/html": htmlBody = string(data) - } else if contentType == "text/plain" && bodyText == "" { + case contentType == "text/plain" && bodyText == "": bodyText = string(data) } } @@ -472,7 +474,7 @@ func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) { var scanErr error for { part, err := mr.NextPart() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/calendar/calendar.go b/calendar/calendar.go index 1a02deaa4ce92ebccc786de5ea463c2919b742a4..16feb0cf720a54dbc905c64b263afaba525cf48a 100644 --- a/calendar/calendar.go +++ b/calendar/calendar.go @@ -116,7 +116,7 @@ func GenerateRSVP(originalData []byte, userEmail, response string) ([]byte, erro // Attendee not found in original - add ourselves with full parameters vevent.AddAttendee("mailto:"+userEmail, ics.WithRSVP(true), - ics.ParticipationStatus(ics.ParticipationStatusNeedsAction), + ics.ParticipationStatusNeedsAction, ics.CalendarUserTypeIndividual, ics.ParticipationRoleReqParticipant, ) diff --git a/cli/config.go b/cli/config.go index e878b0febb164f715e743bc47b178a90e76836cd..73b602dd7dc105d095f42119c9d224c3220d1c04 100644 --- a/cli/config.go +++ b/cli/config.go @@ -26,7 +26,7 @@ func RunConfig(args []string) error { name := args[0] // Add .lua extension if not present if filepath.Ext(name) != ".lua" { - name = name + ".lua" + name += ".lua" } target = filepath.Join(home, ".config", "matcha", "plugins", name) } @@ -35,7 +35,7 @@ func RunConfig(args []string) error { return fmt.Errorf("file not found: %s", target) } - cmd := exec.Command(editor, target) + cmd := exec.Command(editor, target) //nolint:gosec,noctx cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/cli/contacts_export.go b/cli/contacts_export.go index c85ad06d202aa2dc66df8cfd56a5bd4c5f7d9ef5..1fd4206d569e2b7a041a2e767d2036def96755d0 100644 --- a/cli/contacts_export.go +++ b/cli/contacts_export.go @@ -101,7 +101,7 @@ func runExportContacts(format, outputPath string, noHeader bool) error { if outputPath != "" { dir := filepath.Dir(outputPath) if dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0750); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } } diff --git a/cli/install.go b/cli/install.go index 59844f49aedbddcc158e16c3bbeffbff1d5a2d3b..8f6a9024430eb301b462e2f5e18e1878d4562dd5 100644 --- a/cli/install.go +++ b/cli/install.go @@ -24,11 +24,11 @@ func RunInstall(args []string) error { if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { // Download from URL client := httpclient.New(httpclient.InstallTimeout) - resp, err := client.Get(source) + resp, err := client.Get(source) //nolint:noctx if err != nil { return fmt.Errorf("failed to download: %w", err) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { return fmt.Errorf("download returned status %d", resp.StatusCode) @@ -62,7 +62,7 @@ func RunInstall(args []string) error { } dest := filepath.Join(pluginsDir, filename) - if err := os.WriteFile(dest, data, 0644); err != nil { + if err := os.WriteFile(dest, data, 0644); err != nil { //nolint:gosec return fmt.Errorf("failed to write plugin: %w", err) } @@ -76,7 +76,7 @@ func pluginsDir() (string, error) { return "", fmt.Errorf("cannot find home directory: %w", err) } dir := filepath.Join(home, ".config", "matcha", "plugins") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0750); err != nil { return "", fmt.Errorf("cannot create plugins directory: %w", err) } return dir, nil diff --git a/cli/integration.go b/cli/integration.go index b27a6273fa0356c2c549d7785354abd4c3681c20..9cdbe1bb447b675258983081b140309d4edeaec2 100644 --- a/cli/integration.go +++ b/cli/integration.go @@ -54,14 +54,14 @@ MimeType=x-scheme-handler/mailto; } iconsDir := filepath.Join(home, ".local", "share", "icons", "hicolor", "512x512", "apps") - if err := os.MkdirAll(iconsDir, 0755); err == nil { + if err := os.MkdirAll(iconsDir, 0750); err == nil { iconFile := filepath.Join(iconsDir, "matcha.png") _ = os.WriteFile(iconFile, assets.Logo, 0644) - _ = exec.Command("gtk-update-icon-cache", filepath.Join(home, ".local", "share", "icons", "hicolor")).Run() + _ = exec.Command("gtk-update-icon-cache", filepath.Join(home, ".local", "share", "icons", "hicolor")).Run() //nolint:noctx } appsDir := filepath.Join(home, ".local", "share", "applications") - if err := os.MkdirAll(appsDir, 0755); err != nil { + if err := os.MkdirAll(appsDir, 0750); err != nil { return err } @@ -70,13 +70,11 @@ MimeType=x-scheme-handler/mailto; return err } - // Update desktop database - if err := exec.Command("update-desktop-database", appsDir).Run(); err != nil { - // Ignore error if command doesn't exist - } + // Update desktop database (ignore error if command doesn't exist) + _ = exec.Command("update-desktop-database", appsDir).Run() //nolint:noctx // Try to set xdg-mime default - cmd := exec.Command("xdg-mime", "default", "matcha.desktop", "x-scheme-handler/mailto") + cmd := exec.Command("xdg-mime", "default", "matcha.desktop", "x-scheme-handler/mailto") //nolint:noctx if err := cmd.Run(); err != nil { return fmt.Errorf("failed to run xdg-mime: %w", err) } @@ -96,16 +94,16 @@ func setupMailtoDarwin(exe string) error { appDir := filepath.Join(home, "Applications", "MatchaMail.app") // Cleanup old version to avoid conflicts - os.RemoveAll(appDir) + os.RemoveAll(appDir) //nolint:errcheck,gosec contentsDir := filepath.Join(appDir, "Contents") macosDir := filepath.Join(contentsDir, "MacOS") resourcesDir := filepath.Join(contentsDir, "Resources") - if err := os.MkdirAll(macosDir, 0755); err != nil { + if err := os.MkdirAll(macosDir, 0750); err != nil { return err } - if err := os.MkdirAll(resourcesDir, 0755); err != nil { + if err := os.MkdirAll(resourcesDir, 0750); err != nil { return err } @@ -113,8 +111,8 @@ func setupMailtoDarwin(exe string) error { tmpLogo := filepath.Join(os.TempDir(), "matcha_logo.png") if err := os.WriteFile(tmpLogo, assets.Logo, 0644); err == nil { icnsPath := filepath.Join(resourcesDir, "MatchaMail.icns") - _ = exec.Command("sips", "-s", "format", "icns", tmpLogo, "--out", icnsPath).Run() - os.Remove(tmpLogo) + _ = exec.Command("sips", "-s", "format", "icns", tmpLogo, "--out", icnsPath).Run() //nolint:noctx + os.Remove(tmpLogo) //nolint:errcheck,gosec } infoPlist := ` @@ -164,19 +162,19 @@ func setupMailtoDarwin(exe string) error { if err := os.WriteFile(tmpSwiftFile, []byte(swiftCode), 0644); err != nil { return err } - defer os.Remove(tmpSwiftFile) + defer os.Remove(tmpSwiftFile) //nolint:errcheck exeDest := filepath.Join(macosDir, "MatchaMail") // Compile the Swift file - cmd := exec.Command("swiftc", "-O", tmpSwiftFile, "-o", exeDest) + cmd := exec.Command("swiftc", "-O", tmpSwiftFile, "-o", exeDest) //nolint:noctx if err := cmd.Run(); err != nil { return fmt.Errorf("failed to compile Swift handler app: %w", err) } // Register the application lsregister := "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" - _ = exec.Command(lsregister, "-f", appDir).Run() + _ = exec.Command(lsregister, "-f", appDir).Run() //nolint:noctx fmt.Printf("Successfully created %s.\n", appDir) diff --git a/clib/htmlconv.go b/clib/htmlconv.go index 5d3b1e54812d9a19eddf46f845d2ac5c1c2af615..39c7d6e82344577a97cc54f35aba20dcdca940e2 100644 --- a/clib/htmlconv.go +++ b/clib/htmlconv.go @@ -23,7 +23,7 @@ func HTMLToElements(html string) ([]HTMLElement, bool) { if result.ok == 0 { return nil, false } - defer C.free_html_result(&result) + defer C.free_html_result(&result) //nolint:gocritic count := int(result.count) if count == 0 { diff --git a/clib/imgconv.go b/clib/imgconv.go index 7f1705908ae20ec2befeed77007cfb61f50e14b7..6dc81c1b0ff28567c2e070fcbc15925c5ed2f5aa 100644 --- a/clib/imgconv.go +++ b/clib/imgconv.go @@ -26,7 +26,7 @@ func DecodeToPNG(data []byte) (ImageConvertResult, bool) { if result.ok == 0 { return ImageConvertResult{}, false } - defer C.free_image_result(&result) + defer C.free_image_result(&result) //nolint:gocritic pngData := C.GoBytes(unsafe.Pointer(result.png_data), C.int(result.png_len)) diff --git a/clib/macos/appearance.go b/clib/macos/appearance.go index 880d742fc39fc3f9ae1a9060990a61c616983cb3..b01a99f8569d4bcea764af3ea08e0dc1c2b71231 100644 --- a/clib/macos/appearance.go +++ b/clib/macos/appearance.go @@ -28,7 +28,7 @@ func GetAppearance() (*MacOSAppearance, error) { if err != nil { return nil, err } - defer os.RemoveAll(tmpDir) + defer os.RemoveAll(tmpDir) //nolint:errcheck swiftFile := filepath.Join(tmpDir, "appearance.swift") if err := os.WriteFile(swiftFile, []byte(appearanceSwift), 0644); err != nil { @@ -38,13 +38,13 @@ func GetAppearance() (*MacOSAppearance, error) { binFile := filepath.Join(tmpDir, "appearance") // Compile - cmd := exec.Command("swiftc", swiftFile, "-o", binFile) + cmd := exec.Command("swiftc", swiftFile, "-o", binFile) //nolint:noctx if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("failed to compile appearance helper: %w\n%s", err, string(out)) } // Run - out, err := exec.Command(binFile).Output() + out, err := exec.Command(binFile).Output() //nolint:noctx if err != nil { return nil, fmt.Errorf("failed to run appearance helper: %w", err) } diff --git a/clib/macos/badge.go b/clib/macos/badge.go index 0e623b5dbf3ada7380253a04791c39c59427bb42..33da36a18e99501d85d4a305fae194181b491fad 100644 --- a/clib/macos/badge.go +++ b/clib/macos/badge.go @@ -23,7 +23,7 @@ func SetBadge(count int) error { if err != nil { return err } - defer os.RemoveAll(tmpDir) + defer os.RemoveAll(tmpDir) //nolint:errcheck swiftFile := filepath.Join(tmpDir, "badge.swift") if err := os.WriteFile(swiftFile, []byte(badgeSwift), 0644); err != nil { @@ -33,7 +33,7 @@ func SetBadge(count int) error { binFile := filepath.Join(tmpDir, "badge") // Compile - cmd := exec.Command("swiftc", swiftFile, "-o", binFile) + cmd := exec.Command("swiftc", swiftFile, "-o", binFile) //nolint:noctx if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to compile badge helper: %w\n%s", err, string(out)) } @@ -44,7 +44,7 @@ func SetBadge(count int) error { // To set it for the 'MatchaMail.app', we'd need that app to be running and // listen for a notification, OR we run this compiled tool *inside* the app bundle context. - err = exec.Command(binFile, strconv.Itoa(count)).Run() + err = exec.Command(binFile, strconv.Itoa(count)).Run() //nolint:noctx if err != nil { return fmt.Errorf("failed to set badge: %w", err) } diff --git a/clib/macos/contacts.go b/clib/macos/contacts.go index 147b9ae604a39044ac2854c71853c5b39b69f269..8f314199a52126db696914805adbe429d0f7c989 100644 --- a/clib/macos/contacts.go +++ b/clib/macos/contacts.go @@ -28,7 +28,7 @@ func FetchContacts() ([]MacOSContact, error) { if err != nil { return nil, err } - defer os.RemoveAll(tmpDir) + defer os.RemoveAll(tmpDir) //nolint:errcheck swiftFile := filepath.Join(tmpDir, "contacts.swift") if err := os.WriteFile(swiftFile, []byte(contactsSwift), 0644); err != nil { @@ -38,13 +38,13 @@ func FetchContacts() ([]MacOSContact, error) { binFile := filepath.Join(tmpDir, "contacts") // Compile the Swift helper - cmd := exec.Command("swiftc", swiftFile, "-o", binFile) + cmd := exec.Command("swiftc", swiftFile, "-o", binFile) //nolint:noctx if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("failed to compile contacts helper: %w\n%s", err, string(out)) } // Run the helper - out, err := exec.Command(binFile).Output() + out, err := exec.Command(binFile).Output() //nolint:noctx if err != nil { return nil, fmt.Errorf("failed to run contacts helper: %w", err) } diff --git a/clib/macos/file_picker.go b/clib/macos/file_picker.go index b0a476430126449998fe7531607cc91ed7a9d2fe..596296bab9c950830f64edd667cfac99910e8862 100644 --- a/clib/macos/file_picker.go +++ b/clib/macos/file_picker.go @@ -24,7 +24,7 @@ func OpenFilePicker(initialPath string) ([]string, error) { if err != nil { return nil, err } - defer os.RemoveAll(tmpDir) + defer os.RemoveAll(tmpDir) //nolint:errcheck swiftFile := filepath.Join(tmpDir, "file_picker.swift") if err := os.WriteFile(swiftFile, []byte(filePickerSwift), 0644); err != nil { @@ -34,7 +34,7 @@ func OpenFilePicker(initialPath string) ([]string, error) { binFile := filepath.Join(tmpDir, "file_picker") // Compile - cmd := exec.Command("swiftc", swiftFile, "-o", binFile) + cmd := exec.Command("swiftc", swiftFile, "-o", binFile) //nolint:noctx if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("failed to compile file picker helper: %w\n%s", err, string(out)) } @@ -44,7 +44,7 @@ func OpenFilePicker(initialPath string) ([]string, error) { if initialPath != "" { args = append(args, initialPath) } - out, err := exec.Command(binFile, args...).Output() + out, err := exec.Command(binFile, args...).Output() //nolint:noctx if err != nil { // Exit code 1 usually means user cancelled return nil, nil diff --git a/config/cache.go b/config/cache.go index af259335cbb761222c467595f0c9ef83f2d8e3e5..5ffad483f3282a55f893602631d49061047c7979 100644 --- a/config/cache.go +++ b/config/cache.go @@ -378,7 +378,7 @@ func SearchContactsForAccount(query, accountID string) []Contact { func MigrateContactsCacheUsage(accountIDs []string) error { cache, err := LoadContactsCache() if err != nil { - return nil + return err } changed := false @@ -539,7 +539,7 @@ func SaveDraft(draft Draft) error { func DeleteDraft(id string) error { cache, err := LoadDraftsCache() if err != nil { - return nil // No cache, nothing to delete + return err } var filtered []Draft @@ -728,14 +728,6 @@ func calculateEmailBodySize(body *CachedEmailBody) int { return size } -func calculateTotalCacheSize(cache *EmailBodyCache) int { - total := 0 - for _, b := range cache.Bodies { - total += b.SizeBytes - } - return total -} - // SaveEmailBody saves or updates a cached email body for a folder. func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error { body.CachedAt = time.Now() @@ -753,7 +745,7 @@ func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string, thresho cache, err := LoadEmailBodyCache(folderName) if err != nil { - return nil + return err } lru := GetLRUInstance(threshold) diff --git a/config/config.go b/config/config.go index a527e7616a9b0229810c59e36bc3c37267907439..985200288747b3aa0fe54792b8154e326944c1df 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "crypto/tls" "encoding/json" + "errors" "fmt" "log" "os" @@ -16,6 +17,12 @@ import ( const keyringServiceName = "matcha-email-client" +const ( + ProviderGmail = "gmail" + ProviderICloud = "icloud" + ProviderCustom = "custom" +) + // Date format presets use human-readable tokens. Supported tokens: // // YYYY (4-digit year), YY (2-digit year) @@ -194,13 +201,13 @@ func translateDateFormat(f string) string { // GetIMAPServer returns the IMAP server address for the account. func (a *Account) GetIMAPServer() string { switch a.ServiceProvider { - case "gmail": + case ProviderGmail: return "imap.gmail.com" case "outlook": return "outlook.office365.com" - case "icloud": + case ProviderICloud: return "imap.mail.me.com" - case "custom": + case ProviderCustom: return a.IMAPServer default: return "" @@ -210,9 +217,9 @@ func (a *Account) GetIMAPServer() string { // GetIMAPPort returns the IMAP port for the account. func (a *Account) GetIMAPPort() int { switch a.ServiceProvider { - case "gmail", "outlook", "icloud": + case ProviderGmail, "outlook", "icloud": return 993 - case "custom": + case ProviderCustom: if a.IMAPPort != 0 { return a.IMAPPort } @@ -225,13 +232,13 @@ func (a *Account) GetIMAPPort() int { // GetSMTPServer returns the SMTP server address for the account. func (a *Account) GetSMTPServer() string { switch a.ServiceProvider { - case "gmail": + case ProviderGmail: return "smtp.gmail.com" case "outlook": return "smtp.office365.com" - case "icloud": + case ProviderICloud: return "smtp.mail.me.com" - case "custom": + case ProviderCustom: return a.SMTPServer default: return "" @@ -249,9 +256,9 @@ func (a *Account) GetClientSessionCache() tls.ClientSessionCache { // GetSMTPPort returns the SMTP port for the account. func (a *Account) GetSMTPPort() int { switch a.ServiceProvider { - case "gmail", "outlook", "icloud": + case ProviderGmail, "outlook", "icloud": return 587 - case "custom": + case ProviderCustom: if a.SMTPPort != 0 { return a.SMTPPort } @@ -657,18 +664,19 @@ func LoadConfig() (*Config, error) { return nil, fmt.Errorf("account %q: invalid pgp_key_source %q (must be \"file\" or \"yubikey\")", acc.Name, acc.PGPKeySource) } - if secureMode { + switch { + case secureMode: // In secure mode, passwords and PINs are stored in the encrypted config JSON acc.Password = rawAcc.Password acc.PGPPIN = rawAcc.PGPPIN - } else if rawAcc.Password != "" { + case rawAcc.Password != "": // Found a plain-text password! Move it to the OS Keyring. if err := keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password); err != nil { log.Printf("matcha: failed to migrate password for %s into keyring: %v", rawAcc.Email, err) } acc.Password = rawAcc.Password needsMigration = true - } else { + default: // No plaintext password in JSON, fetch from Keyring as normal. if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil { acc.Password = pwd @@ -724,11 +732,11 @@ func (c *Config) RemoveAccount(id string) bool { // missing entry is expected and not worth logging (keyring.Get is // what we rely on elsewhere to detect that), but any other error // means we failed to clean up a still-reachable secret. - if err := keyring.Delete(keyringServiceName, acc.Email); err != nil && err != keyring.ErrNotFound { + if err := keyring.Delete(keyringServiceName, acc.Email); err != nil && !errors.Is(err, keyring.ErrNotFound) { log.Printf("matcha: failed to delete password for %s from keyring: %v", acc.Email, err) } // Delete PGP PIN from OS Keyring if present - if err := keyring.Delete(keyringServiceName, acc.Email+":pgp-pin"); err != nil && err != keyring.ErrNotFound { + if err := keyring.Delete(keyringServiceName, acc.Email+":pgp-pin"); err != nil && !errors.Is(err, keyring.ErrNotFound) { log.Printf("matcha: failed to delete PGP PIN for %s from keyring: %v", acc.Email, err) } diff --git a/config/encryption.go b/config/encryption.go index de7a300c07edb010202a9bc19754e191b90bb9e0..4ecb921fe0d4dfa668d48251b55dcdc4fb2525a4 100644 --- a/config/encryption.go +++ b/config/encryption.go @@ -282,7 +282,7 @@ func DisableSecureMode(cfg *Config) error { if err != nil { continue // File may not be encrypted } - if err := os.WriteFile(f, plain, 0600); err != nil { + if err := os.WriteFile(f, plain, 0600); err != nil { //nolint:gosec return err } } @@ -324,13 +324,13 @@ func SecureReadFile(path string) ([]byte, error) { func SecureWriteFile(path string, data []byte, perm os.FileMode) error { key := GetSessionKey() if key == nil { - return os.WriteFile(path, data, perm) + return os.WriteFile(path, data, perm) //nolint:gosec } encrypted, err := Encrypt(data, key) if err != nil { return err } - return os.WriteFile(path, encrypted, perm) + return os.WriteFile(path, encrypted, perm) //nolint:gosec } // reEncryptCacheFiles reads all plain cache/data files (excluding config.json) and writes them encrypted. diff --git a/config/keybinds.go b/config/keybinds.go index 945f184043d078717e177d985a96cd5c88a573c6..65dd9e4592d500aca21bb105d63182ed45b9e379 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -8,6 +8,8 @@ import ( "path/filepath" ) +const keyDelete = "delete" + //go:embed default_keybinds.json var defaultKeybindsJSON []byte @@ -144,7 +146,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { check("inbox", map[string]string{ "visual_mode": kb.Inbox.VisualMode, "toggle_threaded": kb.Inbox.ToggleThreaded, - "delete": kb.Inbox.Delete, + keyDelete: kb.Inbox.Delete, "archive": kb.Inbox.Archive, "refresh": kb.Inbox.Refresh, "search": kb.Inbox.Search, @@ -156,7 +158,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { check("email", map[string]string{ "reply": kb.Email.Reply, "forward": kb.Email.Forward, - "delete": kb.Email.Delete, + keyDelete: kb.Email.Delete, "archive": kb.Email.Archive, "toggle_images": kb.Email.ToggleImages, "rsvp_accept": kb.Email.RsvpAccept, @@ -168,7 +170,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "external_editor": kb.Composer.ExternalEditor, "next_field": kb.Composer.NextField, "prev_field": kb.Composer.PrevField, - "delete": kb.Composer.Delete, + keyDelete: kb.Composer.Delete, }) check("folder", map[string]string{ "next_folder": kb.Folder.NextFolder, @@ -178,8 +180,8 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "focus_inbox": kb.Folder.FocusInbox, }) check("drafts", map[string]string{ - "open": kb.Drafts.Open, - "delete": kb.Drafts.Delete, + "open": kb.Drafts.Open, + keyDelete: kb.Drafts.Delete, }) return conflicts diff --git a/config/lru.go b/config/lru.go index 0c5799daa7fe9f0d45867643026f1984b225ccc5..3d4c20d184aa6c9f39e9c28a400c91480e9245cc 100644 --- a/config/lru.go +++ b/config/lru.go @@ -41,7 +41,6 @@ func GetLRUInstance(threshold int) *LRU { if err := lru.LoadFromDisk(); err != nil { log.Printf("Failed to load LRU from disk: %v\n", err) } - }) lru.mu.Lock() @@ -65,12 +64,12 @@ func removeBodyFromDisk(folder string, uid uint32, accountID string) error { cache, err := LoadEmailBodyCache(folder) if err != nil { - return nil + return err } kept := cache.Bodies[:0] for _, b := range cache.Bodies { - if !(b.UID == uid && b.AccountID == accountID) { + if b.UID != uid || b.AccountID != accountID { kept = append(kept, b) } } diff --git a/config/oauth.go b/config/oauth.go index f11c3b7ec8848148ee68b074f9cf6095eaf5e272..7af5bf4b89771e0869daf8889e46f9dfd08ae62a 100644 --- a/config/oauth.go +++ b/config/oauth.go @@ -49,7 +49,7 @@ func GetOAuth2Token(email string) (string, error) { return "", err } - cmd := exec.Command("python3", script, "token", email) + cmd := exec.Command("python3", script, "token", email) //nolint:noctx cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { @@ -82,7 +82,7 @@ func RunOAuth2Flow(email, provider, clientID, clientSecret string) error { args = append(args, "--client-id", clientID, "--client-secret", clientSecret) } - cmd := exec.Command("python3", args...) + cmd := exec.Command("python3", args...) //nolint:noctx cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() diff --git a/config/signature.go b/config/signature.go index 2bf8486df6ba5ab916d93c0c8bc58f15f055bd3e..91e42c783721767f570e5c28350dd6941242b8bc 100644 --- a/config/signature.go +++ b/config/signature.go @@ -98,7 +98,7 @@ func SaveSignatureForAccount(accountID, signature string) error { } if signature == "" { // Remove the file to fall back to global - os.Remove(path) + os.Remove(path) //nolint:errcheck,gosec return nil } return SecureWriteFile(path, []byte(signature), 0600) diff --git a/daemon/daemon.go b/daemon/daemon.go index 20cdfb8897c3d138463c9f0024086d425b996b09..b8d57116bd6a943653b39543c3106922ce597778 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -18,6 +18,8 @@ import ( "github.com/floatpane/matcha/notify" ) +const inboxFolder = "INBOX" + // Daemon is the long-running background process that manages email // connections, caching, sync, and notifications. type Daemon struct { @@ -83,22 +85,22 @@ func (d *Daemon) Run() error { if err := WritePID(pidPath); err != nil { return fmt.Errorf("write PID file: %w", err) } - defer RemovePID(pidPath) + defer RemovePID(pidPath) //nolint:errcheck // Remove stale socket file. sockPath := daemonrpc.SocketPath() - os.Remove(sockPath) + os.Remove(sockPath) //nolint:errcheck,gosec // Listen on Unix domain socket. var err error - d.listener, err = net.Listen("unix", sockPath) + d.listener, err = net.Listen("unix", sockPath) //nolint:noctx if err != nil { return fmt.Errorf("listen: %w", err) } - defer d.listener.Close() + defer d.listener.Close() //nolint:errcheck // Set socket permissions (owner only). - os.Chmod(sockPath, 0700) + os.Chmod(sockPath, 0700) //nolint:errcheck,gosec log.Printf("daemon: listening on %s (PID %d)", sockPath, os.Getpid()) @@ -125,7 +127,7 @@ func (d *Daemon) Run() error { // Cleanup. log.Println("daemon: shutting down") - d.listener.Close() + d.listener.Close() //nolint:errcheck,gosec d.idleWatcher.StopAll() cancel() d.closeAllClients() @@ -215,7 +217,7 @@ func (d *Daemon) acceptLoop() { func (d *Daemon) handleClient(conn *daemonrpc.Conn) { defer d.removeClient(conn) - defer conn.Close() + defer conn.Close() //nolint:errcheck for { msg, err := conn.ReceiveMessage() @@ -252,7 +254,7 @@ func (d *Daemon) closeAllClients() { d.mu.Lock() defer d.mu.Unlock() for conn := range d.clients { - conn.Close() + conn.Close() //nolint:errcheck,gosec } d.clients = make(map[*daemonrpc.Conn]struct{}) } @@ -339,9 +341,9 @@ func (d *Daemon) syncAllAccounts(ctx context.Context) { default: } - d.broadcastToSubscribers(acct.ID, "INBOX", daemonrpc.EventSyncStarted, daemonrpc.SyncStartedEvent{ + d.broadcastToSubscribers(acct.ID, inboxFolder, daemonrpc.EventSyncStarted, daemonrpc.SyncStartedEvent{ AccountID: acct.ID, - Folder: "INBOX", + Folder: inboxFolder, }) p, err := d.getProvider(acct.ID) @@ -349,18 +351,18 @@ func (d *Daemon) syncAllAccounts(ctx context.Context) { continue } - emails, err := p.FetchEmails(ctx, "INBOX", 50, 0) + emails, err := p.FetchEmails(ctx, inboxFolder, 50, 0) if err != nil { log.Printf("daemon: sync %s failed: %v", acct.Email, err) - d.broadcastToSubscribers(acct.ID, "INBOX", daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{ + d.broadcastToSubscribers(acct.ID, inboxFolder, daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{ AccountID: acct.ID, - Folder: "INBOX", + Folder: inboxFolder, Error: err.Error(), }) continue } - oldCached, _ := config.LoadFolderEmailCache("INBOX") + oldCached, _ := config.LoadFolderEmailCache(inboxFolder) oldUIDs := make(map[uint32]struct{}, len(oldCached)) for _, e := range oldCached { if e.AccountID == acct.ID { @@ -384,13 +386,13 @@ func (d *Daemon) syncAllAccounts(ctx context.Context) { IsRead: e.IsRead, }) } - if err := d.updateFolderCache("INBOX", acct.ID, cached); err != nil { + if err := d.updateFolderCache(inboxFolder, acct.ID, cached); err != nil { log.Printf("daemon: cache update for INBOX failed: %v", err) } - d.broadcastToSubscribers(acct.ID, "INBOX", daemonrpc.EventSyncComplete, daemonrpc.SyncCompleteEvent{ + d.broadcastToSubscribers(acct.ID, inboxFolder, daemonrpc.EventSyncComplete, daemonrpc.SyncCompleteEvent{ AccountID: acct.ID, - Folder: "INBOX", + Folder: inboxFolder, EmailCount: len(emails), }) @@ -408,7 +410,7 @@ func (d *Daemon) syncAllAccounts(ctx context.Context) { if noClients && newCount > 0 { if !d.config.DisableNotifications { - go notify.Send("Matcha", fmt.Sprintf("New mail for %s", acct.FetchEmail)) + go notify.Send("Matcha", fmt.Sprintf("New mail for %s", acct.FetchEmail)) //nolint:errcheck } } } @@ -429,7 +431,7 @@ func (d *Daemon) startIdleWatchers() { if protocol != "imap" { continue } - d.idleWatcher.Watch(acct, "INBOX") + d.idleWatcher.Watch(acct, inboxFolder) log.Printf("daemon: IDLE watcher started for %s", acct.Email) } } @@ -456,7 +458,7 @@ func (d *Daemon) idleEventLoop() { if acct := d.config.GetAccountByID(update.AccountID); acct != nil { accountName = acct.Email } - go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", update.FolderName, accountName)) + go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", update.FolderName, accountName)) //nolint:errcheck } // Broadcast to subscribed clients. diff --git a/daemon/handler.go b/daemon/handler.go index d2ec60983ca906e4a170a51af838b3c23736bd8a..dd8319a655f4e68a4804f0c649681f27c5ef2907 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -51,7 +51,7 @@ func (d *Daemon) handleRequest(conn *daemonrpc.Conn, req *daemonrpc.Request) { case daemonrpc.MethodUnsubscribe: d.handleUnsubscribe(conn, req) default: - conn.SendError(req.ID, daemonrpc.ErrCodeNotFound, fmt.Sprintf("unknown method: %s", req.Method)) + conn.SendError(req.ID, daemonrpc.ErrCodeNotFound, fmt.Sprintf("unknown method: %s", req.Method)) //nolint:errcheck,gosec } } @@ -66,18 +66,18 @@ func decodeParams[T any](req *daemonrpc.Request) (T, error) { } func (d *Daemon) handlePing(conn *daemonrpc.Conn, req *daemonrpc.Request) { - conn.SendResponse(req.ID, daemonrpc.PingResult{Pong: true}) + conn.SendResponse(req.ID, daemonrpc.PingResult{Pong: true}) //nolint:errcheck,gosec } func (d *Daemon) handleGetStatus(conn *daemonrpc.Conn, req *daemonrpc.Request) { d.mu.RLock() - var accounts []string + accounts := make([]string, 0, len(d.config.Accounts)) for _, acct := range d.config.Accounts { accounts = append(accounts, acct.Email) } d.mu.RUnlock() - conn.SendResponse(req.ID, daemonrpc.StatusResult{ + conn.SendResponse(req.ID, daemonrpc.StatusResult{ //nolint:errcheck,gosec Running: true, Uptime: int64(time.Since(d.startTime).Seconds()), Accounts: accounts, @@ -89,7 +89,7 @@ func (d *Daemon) handleGetAccounts(conn *daemonrpc.Conn, req *daemonrpc.Request) d.mu.RLock() defer d.mu.RUnlock() - var infos []daemonrpc.AccountInfo + infos := make([]daemonrpc.AccountInfo, 0, len(d.config.Accounts)) for _, acct := range d.config.Accounts { protocol := acct.Protocol if protocol == "" { @@ -102,27 +102,27 @@ func (d *Daemon) handleGetAccounts(conn *daemonrpc.Conn, req *daemonrpc.Request) Protocol: protocol, }) } - conn.SendResponse(req.ID, infos) + conn.SendResponse(req.ID, infos) //nolint:errcheck,gosec } func (d *Daemon) handleReloadConfig(conn *daemonrpc.Conn, req *daemonrpc.Request) { if err := d.ReloadConfig(); err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } - conn.SendResponse(req.ID, true) + conn.SendResponse(req.ID, true) //nolint:errcheck,gosec } func (d *Daemon) handleFetchEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.FetchEmailsParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } p, err := d.getProvider(params.AccountID) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } @@ -131,23 +131,23 @@ func (d *Daemon) handleFetchEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) emails, err := p.FetchEmails(ctx, params.Folder, params.Limit, params.Offset) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } - conn.SendResponse(req.ID, emails) + conn.SendResponse(req.ID, emails) //nolint:errcheck,gosec } func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.FetchEmailBodyParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } p, err := d.getProvider(params.AccountID) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } @@ -156,7 +156,7 @@ func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Reque body, mimeType, attachments, err := p.FetchEmailBody(ctx, params.Folder, params.UID) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } @@ -171,7 +171,7 @@ func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Reque }) } - conn.SendResponse(req.ID, daemonrpc.FetchEmailBodyResult{ + conn.SendResponse(req.ID, daemonrpc.FetchEmailBodyResult{ //nolint:errcheck,gosec Body: body, BodyMIMEType: mimeType, Attachments: attInfos, @@ -181,13 +181,13 @@ func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Reque func (d *Daemon) handleDeleteEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.DeleteEmailsParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } p, err := d.getProvider(params.AccountID) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } @@ -195,22 +195,22 @@ func (d *Daemon) handleDeleteEmails(conn *daemonrpc.Conn, req *daemonrpc.Request defer cancel() if err := p.DeleteEmails(ctx, params.Folder, params.UIDs); err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } - conn.SendResponse(req.ID, true) + conn.SendResponse(req.ID, true) //nolint:errcheck,gosec } func (d *Daemon) handleArchiveEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.ArchiveEmailsParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } p, err := d.getProvider(params.AccountID) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } @@ -218,22 +218,22 @@ func (d *Daemon) handleArchiveEmails(conn *daemonrpc.Conn, req *daemonrpc.Reques defer cancel() if err := p.ArchiveEmails(ctx, params.Folder, params.UIDs); err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } - conn.SendResponse(req.ID, true) + conn.SendResponse(req.ID, true) //nolint:errcheck,gosec } func (d *Daemon) handleMoveEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.MoveEmailsParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } p, err := d.getProvider(params.AccountID) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } @@ -241,22 +241,22 @@ func (d *Daemon) handleMoveEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) defer cancel() if err := p.MoveEmails(ctx, params.UIDs, params.SourceFolder, params.DestFolder); err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } - conn.SendResponse(req.ID, true) + conn.SendResponse(req.ID, true) //nolint:errcheck,gosec } func (d *Daemon) handleMarkRead(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.MarkReadParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } p, err := d.getProvider(params.AccountID) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } @@ -274,19 +274,19 @@ func (d *Daemon) handleMarkRead(conn *daemonrpc.Conn, req *daemonrpc.Request) { log.Printf("daemon: mark read=%v %d failed: %v", params.Read, uid, err) } } - conn.SendResponse(req.ID, true) + conn.SendResponse(req.ID, true) //nolint:errcheck,gosec } func (d *Daemon) handleFetchFolders(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.FetchFoldersParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } p, err := d.getProvider(params.AccountID) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } @@ -295,16 +295,16 @@ func (d *Daemon) handleFetchFolders(conn *daemonrpc.Conn, req *daemonrpc.Request folders, err := p.FetchFolders(ctx) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec return } - conn.SendResponse(req.ID, folders) + conn.SendResponse(req.ID, folders) //nolint:errcheck,gosec } func (d *Daemon) handleRefreshFolder(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.RefreshFolderParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } @@ -327,10 +327,7 @@ func (d *Daemon) handleRefreshFolder(conn *daemonrpc.Conn, req *daemonrpc.Reques return } - d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncStarted, daemonrpc.SyncStartedEvent{ - AccountID: params.AccountID, - Folder: params.Folder, - }) + d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncStarted, daemonrpc.SyncStartedEvent(params)) ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() @@ -352,13 +349,13 @@ func (d *Daemon) handleRefreshFolder(conn *daemonrpc.Conn, req *daemonrpc.Reques }) }() - conn.SendResponse(req.ID, true) + conn.SendResponse(req.ID, true) //nolint:errcheck,gosec } func (d *Daemon) handleSubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.SubscribeParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } @@ -372,13 +369,13 @@ func (d *Daemon) handleSubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) { d.subMu.Unlock() log.Printf("daemon: client subscribed to %s", key) - conn.SendResponse(req.ID, true) + conn.SendResponse(req.ID, true) //nolint:errcheck,gosec } func (d *Daemon) handleUnsubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) { params, err := decodeParams[daemonrpc.UnsubscribeParams](req) if err != nil { - conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) + conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec return } @@ -390,5 +387,5 @@ func (d *Daemon) handleUnsubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) } d.subMu.Unlock() - conn.SendResponse(req.ID, true) + conn.SendResponse(req.ID, true) //nolint:errcheck,gosec } diff --git a/daemonclient/client.go b/daemonclient/client.go index 47a98ce52c157f0b5a772bdc4663cea8cec61960..87ce1e99f72eaee7a62d6f483de951734003f0a7 100644 --- a/daemonclient/client.go +++ b/daemonclient/client.go @@ -23,7 +23,7 @@ type Client struct { // Dial connects to the daemon socket. func Dial() (*Client, error) { sockPath := daemonrpc.SocketPath() - conn, err := net.Dial("unix", sockPath) + conn, err := net.Dial("unix", sockPath) //nolint:noctx if err != nil { return nil, fmt.Errorf("connect to daemon: %w", err) } diff --git a/daemonclient/service.go b/daemonclient/service.go index ab10d3ba632457af46386293a21d3b72777145ca..5c7bb684fc06b11d4e802cc74e2d13f96dda6d6e 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -68,7 +68,7 @@ func tryConnect() *daemonService { return nil } if err := client.Ping(); err != nil { - client.Close() + client.Close() //nolint:errcheck,gosec return nil } return &daemonService{client: client} @@ -80,7 +80,7 @@ func autoStartDaemon() error { return err } - cmd := exec.Command(exe, "daemon", "run") + cmd := exec.Command(exe, "daemon", "run") //nolint:noctx cmd.Stdout = nil cmd.Stderr = nil cmd.Stdin = nil @@ -361,7 +361,7 @@ func (s *directService) IsDaemon() bool { return false } func (s *directService) Close() error { for _, p := range s.providers { - p.Close() + p.Close() //nolint:errcheck,gosec } close(s.events) return nil diff --git a/daemonrpc/protocol.go b/daemonrpc/protocol.go index d19d4c7a8fafb3ea662eceb51a419cf88d9c2a62..0aefcbe5b5a30ce672502ba7cb6a2c8d87746055 100644 --- a/daemonrpc/protocol.go +++ b/daemonrpc/protocol.go @@ -50,19 +50,20 @@ func DecodeMessage(raw json.RawMessage) (Message, error) { } var m Message - if probe.Type != "" { + switch { + case probe.Type != "": var ev Event if err := json.Unmarshal(raw, &ev); err != nil { return m, err } m.Event = &ev - } else if probe.Method != "" { + case probe.Method != "": var req Request if err := json.Unmarshal(raw, &req); err != nil { return m, err } m.Request = &req - } else { + default: var resp Response if err := json.Unmarshal(raw, &resp); err != nil { return m, err diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 4a1ef090bdab1e14e2d3fb40e64239d2b831d149..d431524d6eaea9908f4dea7b79d2dcdf60da5c70 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -43,10 +43,16 @@ var ( debugIMAPOnce sync.Once ) +const ( + mimeTextPlain = "text/plain" + mimeTextHTML = "text/html" + partExtracted = "extracted" +) + func getDebugIMAPWriter() io.Writer { debugIMAPOnce.Do(func() { if path := os.Getenv("DEBUG_IMAP"); path != "" { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec if err == nil { debugIMAPFile = f } @@ -84,7 +90,7 @@ type Email struct { ReplyTo []string Subject string Body string - BodyMIMEType string // "text/html" or "text/plain"; empty when unknown (legacy cache rows). Lets the renderer skip markdown→HTML for already-HTML bodies. + BodyMIMEType string // mimeTextHTML or mimeTextPlain; empty when unknown (legacy cache rows). Lets the renderer skip markdown→HTML for already-HTML bodies. Date time.Time IsRead bool MessageID string @@ -374,7 +380,7 @@ func connectWithOptions(account *config.Account, extraOpts *imapclient.Options) options := &imapclient.Options{ TLSConfig: &tls.Config{ ServerName: imapServer, - InsecureSkipVerify: account.Insecure, + InsecureSkipVerify: account.Insecure, //nolint:gosec MinVersion: tls.VersionTLS12, ClientSessionCache: account.GetClientSessionCache(), VerifyConnection: func(cs tls.ConnectionState) error { @@ -409,7 +415,7 @@ func connectWithOptions(account *config.Account, extraOpts *imapclient.Options) } if err := c.WaitGreeting(); err != nil { - c.Close() + c.Close() //nolint:errcheck,gosec return nil, err } @@ -433,7 +439,7 @@ func connectWithOptions(account *config.Account, extraOpts *imapclient.Options) func getSentMailbox(account *config.Account) string { switch account.ServiceProvider { - case "gmail": + case config.ProviderGmail: return "[Gmail]/Sent Mail" case "outlook": return "Sent Items" @@ -447,7 +453,7 @@ func getSentMailbox(account *config.Account) string { // getMailboxByAttr finds a mailbox with the given IMAP attribute (e.g., \All, \Sent, \Trash). func getMailboxByAttr(c *imapclient.Client, attr imap.MailboxAttr) (string, error) { listCmd := c.List("", "*", nil) - defer listCmd.Close() + defer listCmd.Close() //nolint:errcheck var foundMailbox string for { @@ -479,7 +485,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u if err != nil { return nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck selectData, err := c.Select(mailbox, nil).Wait() if err != nil { @@ -493,12 +499,10 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u var allEmails []Email // Start from the top minus offset - cursor := uint32(0) - if selectData.NumMessages > offset { - cursor = selectData.NumMessages - offset - } else { + if selectData.NumMessages <= offset { return []Email{}, nil } + cursor := selectData.NumMessages - offset // Determine if we should filter fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail)) @@ -523,8 +527,8 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u } from := uint32(1) - if cursor > uint32(chunkSize) { - from = cursor - uint32(chunkSize) + 1 + if cursor > chunkSize { + from = cursor - chunkSize + 1 } var seqset imap.SeqSet @@ -568,9 +572,10 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u } matched := false - if account.CatchAll { + switch { + case account.CatchAll: matched = true - } else if isSentMailbox { + case isSentMailbox: var senderEmail string if len(msg.Envelope.From) > 0 { senderEmail = msg.Envelope.From[0].Addr() @@ -578,7 +583,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u if addressMatches(senderEmail, fetchEmail, account) { matched = true } - } else { + default: for _, r := range toAddrList { if addressMatches(r, fetchEmail, account) { matched = true @@ -630,15 +635,15 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u } // FetchEmailBodyFromMailbox returns the chosen body, its MIME type -// ("text/html" or "text/plain"; empty if it could not be resolved), the +// (mimeTextHTML or mimeTextPlain; empty if it could not be resolved), the // parsed attachments, and any error. The MIME type lets the renderer // skip the markdown→HTML pre-pass for already-HTML bodies. -func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, string, []Attachment, error) { +func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, string, []Attachment, error) { //nolint:gocyclo c, err := connect(account) if err != nil { return "", "", nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(mailbox, nil).Wait(); err != nil { return "", "", nil, err @@ -713,7 +718,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint // still letting markdown error messages render formatted. var extractedBodyMIMEType string - var checkPart func(part *imap.BodyStructureSinglePart, partID string) + var checkPart func(part *imap.BodyStructureSinglePart, partID string) //nolint:staticcheck checkPart = func(part *imap.BodyStructureSinglePart, partID string) { // Check for text content (prefer html over plain) if strings.EqualFold(part.Type, "text") { @@ -775,8 +780,8 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if err != nil { extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err) - extractedBodyMIMEType = "text/plain" - htmlPartID = "extracted" + extractedBodyMIMEType = mimeTextPlain + htmlPartID = partExtracted } else { p7, parseErr := pkcs7.Parse(data) if parseErr != nil { @@ -791,8 +796,8 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if parseErr != nil { extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr) - extractedBodyMIMEType = "text/plain" - htmlPartID = "extracted" + extractedBodyMIMEType = mimeTextPlain + htmlPartID = partExtracted } else { var innerBytes []byte isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false @@ -873,7 +878,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint continue } - if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != "text/plain" && cType != "text/html") { + if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != mimeTextPlain && cType != mimeTextHTML) { fn := dParams["filename"] if fn == "" { _, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) @@ -883,21 +888,21 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline", }) } else { - if cType == "text/html" { + if cType == mimeTextHTML { extractedBody = string(b) - extractedBodyMIMEType = "text/html" - htmlPartID = "extracted" // Skip IMAP fetch - } else if cType == "text/plain" && extractedBody == "" { + extractedBodyMIMEType = mimeTextHTML + htmlPartID = partExtracted // Skip IMAP fetch + } else if cType == mimeTextPlain && extractedBody == "" { extractedBody = string(b) - extractedBodyMIMEType = "text/plain" - plainPartID = "extracted" + extractedBodyMIMEType = mimeTextPlain + plainPartID = partExtracted } } } } else { extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes)) - extractedBodyMIMEType = "text/plain" - htmlPartID = "extracted" + extractedBodyMIMEType = mimeTextPlain + htmlPartID = partExtracted } attachments = append(attachments, Attachment{ @@ -907,11 +912,10 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint IsSMIMEEncrypted: isEncrypted, }) return // Stop checking IMAP structure, we hijacked it - } else { - extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr) - extractedBodyMIMEType = "text/plain" - htmlPartID = "extracted" } + extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr) + extractedBodyMIMEType = mimeTextPlain + htmlPartID = partExtracted } } } @@ -967,7 +971,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if err := p7.VerifyWithChain(roots); err == nil { att.SMIMEVerified = true } else { - p7.Content = append(canonical, '\r', '\n') + p7.Content = append(canonical, '\r', '\n') //nolint:gocritic if err := p7.VerifyWithChain(roots); err == nil { att.SMIMEVerified = true } else { @@ -987,13 +991,9 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } // === PGP ENCRYPTED MESSAGE DETECTION === - if mimeType == "application/pgp-encrypted" || (mimeType == "multipart/encrypted" && strings.Contains(part.Subtype, "pgp")) { - // PGP encrypted messages typically have two parts: - // 1. Version info (application/pgp-encrypted) - // 2. Encrypted data (application/octet-stream) - // We'll handle decryption when we find the encrypted data part - // Skip this part and continue processing - } + // PGP encrypted messages have two parts: version info and encrypted data. + // We handle decryption when we find the encrypted data part (application/octet-stream). + // Skip the version info part (application/pgp-encrypted) and continue processing. // Detect encrypted data part of PGP message if strings.Contains(filename, ".asc") || (mimeType == "application/octet-stream" && part.Encoding == "7bit") { @@ -1009,25 +1009,24 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if err == nil { for { p, err := mr.NextPart() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { break } - switch h := p.Header.(type) { - case *mail.InlineHeader: + if h, ok := p.Header.(*mail.InlineHeader); ok { ct, _, _ := h.ContentType() - if strings.HasPrefix(ct, "text/html") { + if strings.HasPrefix(ct, mimeTextHTML) { body, _ := io.ReadAll(p.Body) extractedBody = string(body) - extractedBodyMIMEType = "text/html" + extractedBodyMIMEType = mimeTextHTML htmlPartID = "decrypted" - } else if strings.HasPrefix(ct, "text/plain") && extractedBody == "" { + } else if strings.HasPrefix(ct, mimeTextPlain) && extractedBody == "" { body, _ := io.ReadAll(p.Body) extractedBody = string(body) - extractedBodyMIMEType = "text/plain" + extractedBodyMIMEType = mimeTextPlain plainPartID = "decrypted" } } @@ -1042,19 +1041,19 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } else { extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err) - extractedBodyMIMEType = "text/plain" - htmlPartID = "extracted" + extractedBodyMIMEType = mimeTextPlain + htmlPartID = partExtracted } } else { extractedBody = "**PGP Encrypted:** Private key not configured\n" - extractedBodyMIMEType = "text/plain" - htmlPartID = "extracted" + extractedBodyMIMEType = mimeTextPlain + htmlPartID = partExtracted } } } // === PGP DETACHED SIGNATURE VERIFICATION === - if filename == "signature.asc" || mimeType == "application/pgp-signature" { + if filename == "signature.asc" || mimeType == "application/pgp-signature" { //nolint:gocritic att := Attachment{ Filename: filename, PartID: partID, @@ -1159,11 +1158,11 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if htmlPartID != "" { textPartID = htmlPartID textPartEncoding = htmlPartEncoding - bodyMIMEType = "text/html" + bodyMIMEType = mimeTextHTML } else if plainPartID != "" { textPartID = plainPartID textPartEncoding = plainPartEncoding - bodyMIMEType = "text/plain" + bodyMIMEType = mimeTextPlain } if os.Getenv("DEBUG_KITTY_IMAGES") != "" { msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID) @@ -1172,11 +1171,11 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint // Use a closure with defer so a panic between open and // WriteString doesn't leak the file descriptor (#894). func() { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec if err != nil { return } - defer f.Close() + defer f.Close() //nolint:errcheck _, _ = f.WriteString(msg) }() } @@ -1216,7 +1215,7 @@ func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uin if err != nil { return nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(mailbox, nil).Wait(); err != nil { return nil, err @@ -1258,7 +1257,7 @@ func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox s if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(sourceMailbox, nil).Wait(); err != nil { return err @@ -1274,7 +1273,7 @@ func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint3 if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err @@ -1293,7 +1292,7 @@ func MarkEmailAsUnreadInMailbox(account *config.Account, mailbox string, uid uin if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err @@ -1312,7 +1311,7 @@ func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err @@ -1335,11 +1334,11 @@ func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32 if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck var archiveMailbox string switch account.ServiceProvider { - case "gmail": + case config.ProviderGmail: // For Gmail, find the mailbox with the \All attribute archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { @@ -1371,7 +1370,7 @@ func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uin if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(mailbox, nil).Wait(); err != nil { return err @@ -1399,11 +1398,11 @@ func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []ui if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck var archiveMailbox string switch account.ServiceProvider { - case "gmail": + case config.ProviderGmail: archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { archiveMailbox = "[Gmail]/All Mail" @@ -1431,7 +1430,7 @@ func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, de if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(sourceFolder, nil).Wait(); err != nil { return err @@ -1490,7 +1489,7 @@ func AppendToSentMailbox(account *config.Account, rawMsg []byte) error { if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck sentMailbox := getSentMailbox(account) appendCmd := c.Append(sentMailbox, int64(len(rawMsg)), &imap.AppendOptions{ @@ -1510,7 +1509,7 @@ func AppendToSentMailbox(account *config.Account, rawMsg []byte) error { // getTrashMailbox returns the trash mailbox name for the account func getTrashMailbox(account *config.Account) string { switch account.ServiceProvider { - case "gmail": + case config.ProviderGmail: return "[Gmail]/Trash" case "outlook": return "Deleted Items" @@ -1524,7 +1523,7 @@ func getTrashMailbox(account *config.Account) string { // getArchiveMailbox returns the archive/all mail mailbox name for the account func getArchiveMailbox(account *config.Account) string { switch account.ServiceProvider { - case "gmail": + case config.ProviderGmail: return "[Gmail]/All Mail" case "outlook", "icloud": return "Archive" @@ -1539,7 +1538,7 @@ func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, e if err != nil { return nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck // Try to find trash by attribute first trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash) @@ -1558,7 +1557,7 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, if err != nil { return nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck // Try to find archive by attribute first (Gmail uses \All) archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll) @@ -1690,7 +1689,7 @@ func FetchTrashEmailBody(account *config.Account, uid uint32) (string, string, [ if err != nil { return "", "", nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash) if err != nil { @@ -1706,7 +1705,7 @@ func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, string, if err != nil { return "", "", nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { @@ -1722,7 +1721,7 @@ func FetchTrashAttachment(account *config.Account, uid uint32, partID string, en if err != nil { return nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash) if err != nil { @@ -1738,7 +1737,7 @@ func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, if err != nil { return nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { @@ -1754,7 +1753,7 @@ func DeleteTrashEmail(account *config.Account, uid uint32) error { if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash) if err != nil { @@ -1770,7 +1769,7 @@ func DeleteArchiveEmail(account *config.Account, uid uint32) error { if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll) if err != nil { @@ -1786,10 +1785,10 @@ func FetchFolders(account *config.Account) ([]Folder, error) { if err != nil { return nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck listCmd := c.List("", "*", nil) - defer listCmd.Close() + defer listCmd.Close() //nolint:errcheck var folders []Folder for { diff --git a/fetcher/idle.go b/fetcher/idle.go index cb8c60fa3c8e356471c123b5bb2b90fd1cb9c133..7757b04e531d984ba1be08a3cfdf365253bf876a 100644 --- a/fetcher/idle.go +++ b/fetcher/idle.go @@ -89,7 +89,7 @@ func (w *IdleWatcher) StopAll() { // StopAllAndWait stops all IDLE watchers and waits for them to finish. func (w *IdleWatcher) StopAllAndWait() { w.mu.Lock() - var pending []chan struct{} + pending := make([]chan struct{}, 0, len(w.watchers)) for id, a := range w.watchers { close(a.stop) pending = append(pending, a.done) @@ -175,7 +175,7 @@ func (a *accountIdle) idleOnce() error { if err != nil { return err } - defer c.Close() + defer c.Close() //nolint:errcheck // Select the mailbox in read-only mode selectData, err := c.Select(a.folder, nil).Wait() @@ -193,8 +193,8 @@ func (a *accountIdle) idleOnce() error { for { select { case <-a.stop: - idleCmd.Close() - idleCmd.Wait() + idleCmd.Close() //nolint:errcheck,gosec + idleCmd.Wait() //nolint:errcheck,gosec return nil case newExists := <-mailboxUpdates: @@ -205,8 +205,8 @@ func (a *accountIdle) idleOnce() error { FolderName: a.folder, }: case <-a.stop: - idleCmd.Close() - idleCmd.Wait() + idleCmd.Close() //nolint:errcheck,gosec + idleCmd.Wait() //nolint:errcheck,gosec return nil } } diff --git a/fetcher/search.go b/fetcher/search.go index c8cd34ba8d333332922960e47b45a726fa63ba01..b0333bc4f9d949eff849bdc73933a4d981242d6c 100644 --- a/fetcher/search.go +++ b/fetcher/search.go @@ -15,7 +15,7 @@ func SearchMailbox(account *config.Account, folder string, query backend.SearchQ if err != nil { return nil, err } - defer c.Close() + defer c.Close() //nolint:errcheck if _, err := c.Select(folder, nil).Wait(); err != nil { return nil, err diff --git a/i18n/detector.go b/i18n/detector.go index 8bc68db67fc60768b14243a3cdaa2921076be81c..12c8400cf1975166ebd6da48a23d693b43081195 100644 --- a/i18n/detector.go +++ b/i18n/detector.go @@ -73,8 +73,3 @@ func normalizeLanguageCode(code string) string { return code } - -// isValidLanguage checks if a language code is registered. -func isValidLanguage(code string) bool { - return HasLanguage(code) -} diff --git a/i18n/interpolator.go b/i18n/interpolator.go index 7c68b9b3ddf05deae73b431735858e12e45f0c42..4b09759430028c03f3f1fc068cb4e7f96a4b76ef 100644 --- a/i18n/interpolator.go +++ b/i18n/interpolator.go @@ -8,7 +8,7 @@ import ( // Interpolate replaces placeholders in a template string with values from data. // Supports {key} syntax for variable interpolation. func Interpolate(template string, data map[string]interface{}) string { - if data == nil || len(data) == 0 { + if len(data) == 0 { return template } diff --git a/i18n/loader.go b/i18n/loader.go index 885368e543b227d1eb66869ed7c186b097435292..5bd73d3d69ac2734a0ed93e9de92cfcceaeccbe6 100644 --- a/i18n/loader.go +++ b/i18n/loader.go @@ -59,7 +59,7 @@ func loadFromEmbedded(bundle *Bundle) error { func LoadFromDirectory(bundle *Bundle, dir string) error { entries, err := os.ReadDir(dir) if err != nil { - return fmt.Errorf("%w: %v", ErrLoadFailed, err) + return fmt.Errorf("%w: %w", ErrLoadFailed, err) } for _, entry := range entries { @@ -95,7 +95,7 @@ func LoadFromDirectory(bundle *Bundle, dir string) error { func loadLanguageFile(bundle *Bundle, lang string, data []byte) error { messages, err := ParseJSON(data) if err != nil { - return fmt.Errorf("%w: language %s: %v", ErrParseFailed, lang, err) + return fmt.Errorf("%w: language %s: %w", ErrParseFailed, lang, err) } return bundle.AddMessages(lang, messages) diff --git a/i18n/message.go b/i18n/message.go index 7899a0e01cd32da4921406aafe6aea049fb9c280..3a869f53c85e02ef9908c583f3c4cb9eb9a16992 100644 --- a/i18n/message.go +++ b/i18n/message.go @@ -56,6 +56,10 @@ func (m *Message) GetText(form PluralForm) string { if m.Many != "" { return m.Many } + case Other: + if m.Other != "" { + return m.Other + } } // Fallback to Other or One if m.Other != "" { diff --git a/i18n/template.go b/i18n/template.go index 6df067fcd771f03daedf2304f8d91e87c3d2edc8..33bc573180b5caa083d76a1d1172d3aa3ae80d23 100644 --- a/i18n/template.go +++ b/i18n/template.go @@ -56,7 +56,7 @@ func parseTemplate(s string) []templatePart { for i := 0; i < len(s); i++ { ch := s[i] - if ch == '{' && !inVar { + if ch == '{' && !inVar { //nolint:gocritic // Start of variable if current.Len() > 0 { parts = append(parts, templatePart{isVar: false, value: current.String()}) diff --git a/i18n/validator.go b/i18n/validator.go index dd155db2917d0c3f518679f3bb4c66c7b24c5b89..6b7d753306d5c43b92b02f4225fcd2ea81d37f6d 100644 --- a/i18n/validator.go +++ b/i18n/validator.go @@ -125,9 +125,9 @@ func (v *ValidationResult) String() string { report.WriteString("Errors:\n") for _, err := range v.Errors { if err.Key != "" { - report.WriteString(fmt.Sprintf(" [%s] %s: %s\n", err.Language, err.Key, err.Message)) + fmt.Fprintf(&report, " [%s] %s: %s\n", err.Language, err.Key, err.Message) } else { - report.WriteString(fmt.Sprintf(" [%s] %s\n", err.Language, err.Message)) + fmt.Fprintf(&report, " [%s] %s\n", err.Language, err.Message) } } report.WriteString("\n") @@ -137,9 +137,9 @@ func (v *ValidationResult) String() string { if len(v.Missing) > 0 { report.WriteString("Missing translations:\n") for lang, keys := range v.Missing { - report.WriteString(fmt.Sprintf(" [%s] %d missing keys:\n", lang, len(keys))) + fmt.Fprintf(&report, " [%s] %d missing keys:\n", lang, len(keys)) for _, key := range keys { - report.WriteString(fmt.Sprintf(" - %s\n", key)) + fmt.Fprintf(&report, " - %s\n", key) } } report.WriteString("\n") @@ -149,9 +149,9 @@ func (v *ValidationResult) String() string { if len(v.Extra) > 0 { report.WriteString("Extra translations (not in base):\n") for lang, keys := range v.Extra { - report.WriteString(fmt.Sprintf(" [%s] %d extra keys:\n", lang, len(keys))) + fmt.Fprintf(&report, " [%s] %d extra keys:\n", lang, len(keys)) for _, key := range keys { - report.WriteString(fmt.Sprintf(" - %s\n", key)) + fmt.Fprintf(&report, " - %s\n", key) } } } diff --git a/internal/threading/jwz_test.go b/internal/threading/jwz_test.go index 0b62ec8a88ff50b424728a42740af17404ad81b1..7e10e1f64b1bdbce07a54580e8f064e1be2a8044 100644 --- a/internal/threading/jwz_test.go +++ b/internal/threading/jwz_test.go @@ -11,7 +11,7 @@ func TestBuildThreeMessageChain(t *testing.T) { threads := Build([]EmailHeader{ {ID: "", Subject: "Foo", Date: base, EmailID: "1", Sender: "a"}, {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2", Sender: "b"}, - {ID: "", References: []string{"", ""}, Subject: "Re: Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3", Sender: "c"}, + {ID: "", References: []string{"", ""}, Subject: "Re: Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3", Sender: "c"}, //nolint:dupword }) if len(threads) != 1 { @@ -136,7 +136,7 @@ func TestBuildStableOrderingAcrossCalls(t *testing.T) { func TestCanonicalSubjectNormalizesReplyAndForwardPrefixes(t *testing.T) { tests := map[string]string{ - "Re: Re: Foo": "foo", + "Re: Re: Foo": "foo", //nolint:dupword "Fwd: FW: Foo": "foo", "AW: WG: Tr: Foo": "foo", "Reé: Resp: Foo": "foo", diff --git a/main.go b/main.go index dba5b4ab09619a196776ea9dd9a763376db98b7d..5e26efbec9dd3b458861d5650804a73b819dc775 100644 --- a/main.go +++ b/main.go @@ -73,6 +73,11 @@ var ( httpClient = httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5) ) +const ( + goosDarwin = "darwin" + folderInbox = "INBOX" +) + // UpdateAvailableMsg is sent into the TUI when a newer release is detected. type UpdateAvailableMsg struct { Latest string @@ -101,7 +106,6 @@ type mainModel struct { emailsByAcct map[string][]fetcher.Email width int height int - err error // IMAP IDLE idleWatcher *fetcher.IdleWatcher idleUpdates chan fetcher.IdleUpdate @@ -155,7 +159,6 @@ func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel { body := mailtoURL.Query().Get("body") initialModel.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips) } else { - initialModel.current = tui.NewChoice() } initialModel.config = cfg @@ -228,7 +231,7 @@ func waitForLogEntry(ch <-chan logging.Entry) tea.Cmd { } func (m *mainModel) syncUnreadBadge() { - if runtime.GOOS != "darwin" { + if runtime.GOOS != goosDarwin { return } count := 0 @@ -251,7 +254,7 @@ func (m *mainModel) syncUnreadBadge() { _ = macos.SetBadge(count) } -func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo var cmd tea.Cmd var cmds []tea.Cmd searchWasActive := false @@ -308,7 +311,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { m.idleWatcher.StopAll() if m.service != nil { - m.service.Close() + m.service.Close() //nolint:errcheck,gosec } return m, tea.Quit } @@ -355,7 +358,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := config.SaveDraft(draft); err != nil { log.Printf("Error saving draft: %v", err) } - } m.current = tui.NewChoice() m.current, _ = m.current.Update(m.currentWindowSize()) @@ -523,8 +525,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } // Always ensure INBOX is present, even if cache is empty or stale - if !seen["INBOX"] { - cachedFolders = append([]string{"INBOX"}, cachedFolders...) + if !seen[folderInbox] { + cachedFolders = append([]string{folderInbox}, cachedFolders...) } m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts) m.folderInbox.SetDateFormat(m.config.GetDateFormat()) @@ -532,10 +534,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded) m.folderInbox.SetDisableImages(m.config.DisableImages) // Use cached INBOX emails for instant display (memory first, then disk) - if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 { + if cached, ok := m.folderEmails[folderInbox]; ok && len(cached) > 0 { m.folderInbox.SetEmails(cached, m.config.Accounts) - } else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 { - m.folderEmails["INBOX"] = diskCached + } else if diskCached := loadFolderEmailsFromCache(folderInbox); len(diskCached) > 0 { + m.folderEmails[folderInbox] = diskCached m.emails = diskCached m.emailsByAcct = make(map[string][]fetcher.Email) for _, email := range diskCached { @@ -552,19 +554,19 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.service.IsDaemon() { // Subscribe to INBOX updates if using daemon. for _, acct := range m.config.Accounts { - m.service.Subscribe(acct.ID, "INBOX") + m.service.Subscribe(acct.ID, folderInbox) //nolint:errcheck,gosec } } else { // Start IDLE watchers for all accounts on INBOX for i := range m.config.Accounts { - m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX") + m.idleWatcher.Watch(&m.config.Accounts[i], folderInbox) } } // Fetch folders and INBOX emails in parallel (background refresh) batchCmds := []tea.Cmd{ m.current.Init(), fetchFoldersCmd(m.config), - fetchFolderEmailsCmd(m.config, "INBOX"), + fetchFolderEmailsCmd(m.config, folderInbox), listenForIdleUpdates(m.idleUpdates), } if m.service.IsDaemon() { @@ -587,7 +589,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, f := range folders { names = append(names, f.Name) } - go config.SaveAccountFolders(accID, names) + go config.SaveAccountFolders(accID, names) //nolint:errcheck } // Per-account fetch errors (e.g. broken IMAP login, unreachable // server) are non-fatal: other accounts' folders are still shown. @@ -639,7 +641,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { folders := config.GetCachedFolders(m.config.Accounts[i].ID) if !slices.Contains(folders, msg.FolderName) { if m.service != nil && m.service.IsDaemon() { - m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) + m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) //nolint:errcheck,gosec } else { m.idleWatcher.Stop(m.config.Accounts[i].ID) } @@ -648,9 +650,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.service != nil && m.service.IsDaemon() { // Unsubscribe from old, subscribe to new. if msg.PreviousFolder != "" { - m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) + m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) //nolint:errcheck,gosec } - m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName) + m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName) //nolint:errcheck,gosec } else { m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName) } @@ -916,7 +918,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { accountName = acc.Email } } - go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName)) + go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName)) //nolint:errcheck } // IDLE detected new mail — refetch the folder if we're viewing it @@ -949,7 +951,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { accountName = acc.Email } } - go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName)) + go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName)) //nolint:errcheck } if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder { @@ -1042,7 +1044,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Limit > 0 { limit = msg.Limit } - folderName := "INBOX" + folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() } @@ -1054,7 +1056,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.SearchRequestedMsg: folderName := msg.FolderName if folderName == "" { - folderName = "INBOX" + folderName = folderInbox } return m, m.searchEmailsCmd(msg.Query, folderName, msg.AccountID) @@ -1324,14 +1326,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.ViewEmailMsg: email := msg.Email if email == nil { - email = m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) + email = m.getEmailByUIDAndAccount(msg.UID, msg.AccountID) } else { m.addEmailToStoresIfMissing(*email, msg.Mailbox) } if email == nil { return m, nil } - folderName := "INBOX" + folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() } @@ -1404,10 +1406,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Update the email in our stores - m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.BodyMIMEType, msg.Attachments) + m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.BodyMIMEType, msg.Attachments) // Cache the body to disk - folderForCache := "INBOX" + folderForCache := folderInbox if m.folderInbox != nil { folderForCache = m.folderInbox.GetCurrentFolder() } @@ -1442,7 +1444,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loglevel.Debugf("error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err) } - email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) + email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID) if email == nil { if m.folderInbox != nil { m.current = m.folderInbox @@ -1456,7 +1458,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !email.IsRead && !pluginSuppressed { m.markEmailAsReadInStores(msg.UID, msg.AccountID) - folderName := "INBOX" + folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() } @@ -1467,7 +1469,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Find the index for the email view (used for display purposes) - emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox) + emailIndex := m.getEmailIndex(msg.UID, msg.AccountID) emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages) m.current = emailView m.syncPluginStatus() @@ -1530,7 +1532,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Set reply headers inReplyTo := msg.Email.MessageID - references := append(msg.Email.References, msg.Email.MessageID) + references := append(msg.Email.References, msg.Email.MessageID) //nolint:gocritic composer.SetReplyContext(inReplyTo, references) m.current = composer @@ -1592,7 +1594,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tui.GoToFilePickerMsg: - if runtime.GOOS == "darwin" { + if runtime.GOOS == goosDarwin { return m, func() tea.Msg { wd, _ := os.Getwd() paths, err := macos.OpenFilePicker(wd) @@ -1727,7 +1729,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - folderName := "INBOX" + folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() } @@ -1746,7 +1748,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - folderName := "INBOX" + folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() } @@ -1805,7 +1807,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - folderName := "INBOX" + folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() } @@ -1829,7 +1831,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - folderName := "INBOX" + folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() } @@ -1889,7 +1891,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - email := m.getEmailByIndex(msg.Index, msg.Mailbox) + email := m.getEmailByIndex(msg.Index) if email == nil { m.current = m.previousModel return m, nil @@ -1995,14 +1997,14 @@ func (m *mainModel) logPanelHeight() int { return 7 } -func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email { +func (m *mainModel) getEmailByIndex(index int) *fetcher.Email { if index >= 0 && index < len(m.emails) { return &m.emails[index] } return nil } -func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email { +func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email { for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { return &m.emails[i] @@ -2011,7 +2013,7 @@ func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbo return nil } -func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int { +func (m *mainModel) getEmailIndex(uid uint32, accountID string) int { for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { return i @@ -2020,7 +2022,7 @@ func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.Mail return -1 } -func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body, bodyMIMEType string, attachments []fetcher.Attachment) { +func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body, bodyMIMEType string, attachments []fetcher.Attachment) { for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { m.emails[i].Body = body @@ -2041,8 +2043,8 @@ func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox t } } -func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, mailbox tui.MailboxKind) { - if m.getEmailByUIDAndAccount(email.UID, email.AccountID, mailbox) != nil { +func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, _ tui.MailboxKind) { + if m.getEmailByUIDAndAccount(email.UID, email.AccountID) != nil { return } if m.emailsByAcct == nil { @@ -2117,7 +2119,7 @@ func (m *mainModel) markEmailAsUnreadInStores(uid uint32, accountID string) { func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) { var filtered []fetcher.Email for _, e := range m.emails { - if !(e.UID == uid && e.AccountID == accountID) { + if e.UID != uid || e.AccountID != accountID { filtered = append(filtered, e) } } @@ -2144,7 +2146,6 @@ func (m *mainModel) pluginFlagCmds() []tea.Cmd { } var cmds []tea.Cmd for _, op := range ops { - op := op account := m.config.GetAccountByID(op.AccountID) if account == nil { continue @@ -2316,86 +2317,6 @@ func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email return allEmails } -func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd { - return func() tea.Msg { - emailsByAccount := make(map[string][]fetcher.Email) - var mu sync.Mutex - var wg sync.WaitGroup - - for _, account := range cfg.Accounts { - wg.Add(1) - go func(acc config.Account) { - defer wg.Done() - var emails []fetcher.Email - var err error - switch mailbox { - case tui.MailboxSent: - emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0) - case tui.MailboxTrash: - emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0) - case tui.MailboxArchive: - emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0) - default: - emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0) - } - if err != nil { - log.Printf("Error fetching from %s: %v", acc.Email, err) - return - } - mu.Lock() - emailsByAccount[acc.ID] = emails - mu.Unlock() - }(account) - } - - wg.Wait() - return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox} - } -} - -func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd { - return func() tea.Msg { - var emails []fetcher.Email - var err error - if mailbox == tui.MailboxSent { - emails, err = fetcher.FetchSentEmails(account, limit, offset) - } else { - emails, err = fetcher.FetchEmails(account, limit, offset) - } - if err != nil { - return tui.FetchErr(err) - } - if offset == 0 { - return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} - } - return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} - } -} - -func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd { - return func() tea.Msg { - var emails []fetcher.Email - var err error - switch mailbox { - case tui.MailboxSent: - emails, err = fetcher.FetchSentEmails(account, limit, offset) - case tui.MailboxTrash: - emails, err = fetcher.FetchTrashEmails(account, limit, offset) - case tui.MailboxArchive: - emails, err = fetcher.FetchArchiveEmails(account, limit, offset) - default: - emails, err = fetcher.FetchEmails(account, limit, offset) - } - if err != nil { - return tui.FetchErr(err) - } - if offset == 0 { - return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} - } - return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} - } -} - func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPSearchTimeout) @@ -2463,16 +2384,6 @@ func sortFetcherEmails(emails []fetcher.Email) { }) } -func loadCachedEmails() tea.Cmd { - return func() tea.Msg { - cache, err := config.LoadEmailCache() - if err != nil { - return tui.CachedEmailsLoadedMsg{Cache: nil} - } - return tui.CachedEmailsLoadedMsg{Cache: cache} - } -} - func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd { return func() tea.Msg { emailsByAccount := make(map[string][]fetcher.Email) @@ -2514,7 +2425,7 @@ func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[strin } func emailsToCache(emails []fetcher.Email) []config.CachedEmail { - var cached []config.CachedEmail + cached := make([]config.CachedEmail, 0, len(emails)) for _, email := range emails { cached = append(cached, config.CachedEmail{ UID: email.UID, @@ -2533,7 +2444,7 @@ func emailsToCache(emails []fetcher.Email) []config.CachedEmail { } func cacheToEmails(cached []config.CachedEmail) []fetcher.Email { - var emails []fetcher.Email + emails := make([]fetcher.Email, 0, len(cached)) for _, c := range cached { emails = append(emails, fetcher.Email{ UID: c.UID, @@ -2566,39 +2477,6 @@ func loadFolderEmailsFromCache(folderName string) []fetcher.Email { return cacheToEmails(cached) } -func saveEmailsToCache(emails []fetcher.Email) { - if len(emails) > maxCacheEmails { - emails = emails[:maxCacheEmails] - } - var cachedEmails []config.CachedEmail - for _, email := range emails { - cachedEmails = append(cachedEmails, config.CachedEmail{ - UID: email.UID, - From: email.From, - To: email.To, - Subject: email.Subject, - Date: email.Date, - MessageID: email.MessageID, - InReplyTo: email.InReplyTo, - References: email.References, - AccountID: email.AccountID, - IsRead: email.IsRead, - }) - - // Save sender as a contact - if email.From != "" { - name, emailAddr := parseEmailAddress(email.From) - if err := config.AddContactForAccount(name, emailAddr, email.AccountID); err != nil { - log.Printf("Error saving contact from email: %v", err) - } - } - } - cache := &config.EmailCache{Emails: cachedEmails} - if err := config.SaveEmailCache(cache); err != nil { - log.Printf("Error saving email cache: %v", err) - } -} - // parseEmailAddress parses "Name " or just "email" format func parseEmailAddress(addr string) (name, email string) { addr = strings.TrimSpace(addr) @@ -2616,44 +2494,6 @@ func parseEmailAddress(addr string) (name, email string) { return name, email } -func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { - return func() tea.Msg { - account := cfg.GetAccountByID(accountID) - if account == nil { - return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")} - } - - var ( - body string - bodyMIMEType string - attachments []fetcher.Attachment - err error - ) - switch mailbox { - case tui.MailboxSent: - body, bodyMIMEType, attachments, err = fetcher.FetchSentEmailBody(account, uid) - case tui.MailboxTrash: - body, bodyMIMEType, attachments, err = fetcher.FetchTrashEmailBody(account, uid) - case tui.MailboxArchive: - body, bodyMIMEType, attachments, err = fetcher.FetchArchiveEmailBody(account, uid) - default: - body, bodyMIMEType, attachments, err = fetcher.FetchEmailBody(account, uid) - } - if err != nil { - return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} - } - - return tui.EmailBodyFetchedMsg{ - UID: uid, - Body: body, - BodyMIMEType: bodyMIMEType, - Attachments: attachments, - AccountID: accountID, - Mailbox: mailbox, - } - } -} - func markdownToHTML(md []byte) []byte { return clib.MarkdownToHTML(md) } @@ -2695,7 +2535,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { } // Append quoted text if present (for replies) if msg.QuotedText != "" { - body = body + msg.QuotedText + body += msg.QuotedText } images := make(map[string][]byte) attachments := make(map[string][]byte) @@ -2768,7 +2608,7 @@ func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd { // Send as multipart/alternative with text/calendar; method=REPLY // This iMIP format is required for Google Calendar to recognize the RSVP - references := append(msg.References, msg.InReplyTo) + references := append(msg.References, msg.InReplyTo) //nolint:gocritic rawMsg, err := sender.SendCalendarReply( account, []string{msg.Event.Organizer}, @@ -2794,35 +2634,6 @@ func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd { } } -func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { - return func() tea.Msg { - var err error - switch mailbox { - case tui.MailboxSent: - err = fetcher.DeleteSentEmail(account, uid) - case tui.MailboxTrash: - err = fetcher.DeleteTrashEmail(account, uid) - case tui.MailboxArchive: - err = fetcher.DeleteArchiveEmail(account, uid) - default: - err = fetcher.DeleteEmail(account, uid) - } - return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} - } -} - -func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { - return func() tea.Msg { - var err error - if mailbox == tui.MailboxSent { - err = fetcher.ArchiveSentEmail(account, uid) - } else { - err = fetcher.ArchiveEmail(account, uid) - } - return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} - } -} - // --- External editor command --- // openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result. @@ -2844,19 +2655,19 @@ func openExternalEditor(body string) tea.Cmd { tmpPath := tmpFile.Name() if _, err := tmpFile.WriteString(body); err != nil { - tmpFile.Close() - os.Remove(tmpPath) + tmpFile.Close() //nolint:errcheck,gosec + os.Remove(tmpPath) //nolint:errcheck,gosec return func() tea.Msg { return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)} } } - tmpFile.Close() + tmpFile.Close() //nolint:errcheck,gosec parts := strings.Fields(editor) - args := append(parts[1:], tmpPath) - c := exec.Command(parts[0], args...) + args := append(parts[1:], tmpPath) //nolint:gocritic + c := exec.Command(parts[0], args...) //nolint:gosec,noctx return tea.ExecProcess(c, func(err error) tea.Msg { - defer os.Remove(tmpPath) + defer os.Remove(tmpPath) //nolint:errcheck if err != nil { return tui.EditorFinishedMsg{Err: err} } @@ -3261,7 +3072,7 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding) case tui.MailboxArchive: data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding) - default: + case tui.MailboxInbox: data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding) } @@ -3275,7 +3086,7 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download } downloadsPath := filepath.Join(homeDir, "Downloads") if _, err := os.Stat(downloadsPath); os.IsNotExist(err) { - if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil { + if mkErr := os.MkdirAll(downloadsPath, 0750); mkErr != nil { return tui.AttachmentDownloadedMsg{Err: mkErr} } } @@ -3294,7 +3105,7 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download // Try to create file exclusively. If it already exists, os.OpenFile will return an error // that satisfies os.IsExist(err), so we can increment the candidate. - f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) //nolint:gosec if err != nil { if os.IsExist(err) { // file exists, try next candidate @@ -3327,13 +3138,13 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download go func(p string) { var cmd *exec.Cmd switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", p) + case goosDarwin: + cmd = exec.Command("open", p) //nolint:noctx case "linux": - cmd = exec.Command("xdg-open", p) + cmd = exec.Command("xdg-open", p) //nolint:noctx case "windows": // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title. - cmd = exec.Command("cmd", "/c", "start", "", p) + cmd = exec.Command("cmd", "/c", "start", "", p) //nolint:noctx default: // Unsupported OS: nothing to do. return @@ -3362,10 +3173,10 @@ func detectInstalledVersion() string { } // Try Homebrew (macOS) - if runtime.GOOS == "darwin" { + if runtime.GOOS == goosDarwin { if _, err := exec.LookPath("brew"); err == nil { // `brew list --versions matcha` prints: matcha 1.2.3 - if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil { + if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil { //nolint:noctx parts := strings.Fields(string(out)) if len(parts) >= 2 { return parts[1] @@ -3377,7 +3188,7 @@ func detectInstalledVersion() string { // Try WinGet (Windows) if runtime.GOOS == "windows" { if _, err := exec.LookPath("winget"); err == nil { - if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil { + if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil { //nolint:noctx lines := strings.Split(strings.TrimSpace(string(out)), "\n") for _, line := range lines { if strings.Contains(strings.ToLower(line), "floatpane.matcha") { @@ -3396,7 +3207,7 @@ func detectInstalledVersion() string { // Try snap (Linux) if runtime.GOOS == "linux" { if _, err := exec.LookPath("snap"); err == nil { - if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil { + if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil { //nolint:noctx lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) >= 2 { fields := strings.Fields(lines[1]) @@ -3408,7 +3219,7 @@ func detectInstalledVersion() string { } if _, err := exec.LookPath("flatpak"); err == nil { - if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil { + if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil { //nolint:noctx lines := strings.Split(strings.TrimSpace(string(out)), "\n") for _, line := range lines { line = strings.TrimSpace(line) @@ -3439,7 +3250,7 @@ func checkForUpdatesCmd() tea.Cmd { if err != nil { return nil } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck var rel githubRelease if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { @@ -3492,13 +3303,14 @@ func runOAuthCLI(args []string) { } cmdArgs := append([]string{script}, args...) - cmd := exec.Command("python3", cmdArgs...) + cmd := exec.Command("python3", cmdArgs...) //nolint:gosec,noctx cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { os.Exit(exitErr.ExitCode()) } fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -3677,32 +3489,26 @@ func isFlagSet(fs *flag.FlagSet, name string) bool { return found } -func runUpdateCLI() (err error) { +func runUpdateCLI() (err error) { //nolint:gocyclo const api = "https://api.github.com/repos/floatpane/matcha/releases/latest" resp, err := httpClient.Get(api) if err != nil { return fmt.Errorf("could not query releases: %w", err) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck var rel githubRelease if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { return fmt.Errorf("could not parse release info: %w", err) } - latestTag := rel.TagName - if strings.HasPrefix(latestTag, "v") { - latestTag = latestTag[1:] - } + latestTag := strings.TrimPrefix(rel.TagName, "v") fmt.Printf("Current version: %s\n", version) fmt.Printf("Latest version: %s\n", latestTag) // Quick check: if already up-to-date, exit - cur := version - if strings.HasPrefix(cur, "v") { - cur = cur[1:] - } + cur := strings.TrimPrefix(version, "v") if latestTag == "" || cur == latestTag { fmt.Println("Already up to date.") return nil @@ -3712,7 +3518,7 @@ func runUpdateCLI() (err error) { if _, err := exec.LookPath("brew"); err == nil { fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.") - updateCmd := exec.Command("brew", "update") + updateCmd := exec.Command("brew", "update") //nolint:noctx updateCmd.Stdout = os.Stdout updateCmd.Stderr = os.Stderr if err := updateCmd.Run(); err != nil { @@ -3720,7 +3526,7 @@ func runUpdateCLI() (err error) { // continue to attempt upgrade even if update failed } - upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") + upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx upgradeCmd.Stdout = os.Stdout upgradeCmd.Stderr = os.Stderr if err := upgradeCmd.Run(); err == nil { @@ -3734,10 +3540,10 @@ func runUpdateCLI() (err error) { // Detect snap if _, err := exec.LookPath("snap"); err == nil { // Check if matcha is installed as a snap - cmdCheck := exec.Command("snap", "list", "matcha") + cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx if err := cmdCheck.Run(); err == nil { fmt.Println("Detected Snap package — attempting to refresh.") - cmd := exec.Command("snap", "refresh", "matcha") + cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err == nil { @@ -3751,10 +3557,10 @@ func runUpdateCLI() (err error) { // Detect flatpak if _, err := exec.LookPath("flatpak"); err == nil { // Check if matcha is installed as a flatpak - cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") + cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") //nolint:noctx if err := cmdCheck.Run(); err == nil { fmt.Println("Detected Flatpak package — attempting to update.") - cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") + cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") //nolint:noctx cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err == nil { @@ -3768,10 +3574,10 @@ func runUpdateCLI() (err error) { // Detect WinGet if _, err := exec.LookPath("winget"); err == nil { - cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") + cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx if err := cmdCheck.Run(); err == nil { fmt.Println("Detected WinGet package — attempting to upgrade.") - cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") + cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err == nil { @@ -3821,14 +3627,14 @@ func runUpdateCLI() (err error) { if err != nil { return fmt.Errorf("download failed: %w", err) } - defer respAsset.Body.Close() + defer respAsset.Body.Close() //nolint:errcheck // Create a temp file for the download tmpDir, err := os.MkdirTemp("", "matcha-update-*") if err != nil { return fmt.Errorf("could not create temp dir: %w", err) } - defer os.RemoveAll(tmpDir) + defer os.RemoveAll(tmpDir) //nolint:errcheck assetPath := filepath.Join(tmpDir, assetName) outFile, err := os.Create(assetPath) @@ -3836,7 +3642,7 @@ func runUpdateCLI() (err error) { return fmt.Errorf("could not create temp file: %w", err) } _, err = io.Copy(outFile, respAsset.Body) - outFile.Close() + outFile.Close() //nolint:errcheck,gosec if err != nil { return fmt.Errorf("could not write asset to disk: %w", err) } @@ -3849,12 +3655,12 @@ func runUpdateCLI() (err error) { // Extract the binary from the archive. var binPath string - if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { + if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { //nolint:gocritic f, err := os.Open(assetPath) if err != nil { return fmt.Errorf("could not open archive: %w", err) } - defer f.Close() + defer f.Close() //nolint:errcheck gzr, err := gzip.NewReader(f) if err != nil { return fmt.Errorf("could not create gzip reader: %w", err) @@ -3875,12 +3681,12 @@ func runUpdateCLI() (err error) { if err != nil { return fmt.Errorf("could not create binary file: %w", err) } - if _, err := io.Copy(out, tr); err != nil { - out.Close() + if _, err := io.Copy(out, tr); err != nil { //nolint:gosec + out.Close() //nolint:errcheck,gosec return fmt.Errorf("could not extract binary: %w", err) } - out.Close() - if err := os.Chmod(binPath, 0755); err != nil { + out.Close() //nolint:errcheck,gosec + if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec return fmt.Errorf("could not make binary executable: %w", err) } break @@ -3891,7 +3697,7 @@ func runUpdateCLI() (err error) { if err != nil { return fmt.Errorf("could not open zip archive: %w", err) } - defer zr.Close() + defer zr.Close() //nolint:errcheck for _, zf := range zr.File { name := filepath.Base(zf.Name) if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() { @@ -3902,17 +3708,17 @@ func runUpdateCLI() (err error) { binPath = filepath.Join(tmpDir, binaryName) out, err := os.Create(binPath) if err != nil { - rc.Close() + rc.Close() //nolint:errcheck,gosec return fmt.Errorf("could not create binary file: %w", err) } - if _, err := io.Copy(out, rc); err != nil { - out.Close() - rc.Close() + if _, err := io.Copy(out, rc); err != nil { //nolint:gosec + out.Close() //nolint:errcheck,gosec + rc.Close() //nolint:errcheck,gosec return fmt.Errorf("could not extract binary: %w", err) } - out.Close() - rc.Close() - if err := os.Chmod(binPath, 0755); err != nil { + out.Close() //nolint:errcheck,gosec + rc.Close() //nolint:errcheck,gosec + if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec return fmt.Errorf("could not make binary executable: %w", err) } break @@ -3921,7 +3727,7 @@ func runUpdateCLI() (err error) { } else { // For non-archive assets, assume the asset is the binary itself. binPath = assetPath - if err := os.Chmod(binPath, 0755); err != nil { + if err := os.Chmod(binPath, 0755); err != nil { //nolint:gosec // ignore chmod errors but warn fmt.Printf("warning: could not chmod downloaded binary: %v\n", err) } @@ -3944,8 +3750,8 @@ func runUpdateCLI() (err error) { if err != nil { return fmt.Errorf("could not open new binary: %w", err) } - defer in.Close() - out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + defer in.Close() //nolint:errcheck + out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) //nolint:gosec if err != nil { return fmt.Errorf("could not create temp binary in target dir: %w", err) } @@ -4022,7 +3828,7 @@ func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) { return filtered, level, showLogPanel } -func main() { +func main() { //nolint:gocyclo args, level, showLogPanel := parseGlobalFlags(os.Args) os.Args = args loglevel.Set(level) @@ -4192,7 +3998,7 @@ func main() { } initialModel.plugins = plugins tui.BodyTransformer = func(body string, email fetcher.Email) string { - folder := "INBOX" + folder := folderInbox if initialModel.folderInbox != nil { folder = initialModel.folderInbox.GetCurrentFolder() } @@ -4202,7 +4008,7 @@ func main() { plugins.CallHook(plugin.HookStartup) // Background sync macOS features - if runtime.GOOS == "darwin" { + if runtime.GOOS == goosDarwin { disableNotifications := false if initialModel.config != nil { disableNotifications = initialModel.config.DisableNotifications @@ -4273,7 +4079,7 @@ func runDaemonStart() { os.Exit(1) } - cmd := exec.Command(exe, "daemon", "run") + cmd := exec.Command(exe, "daemon", "run") //nolint:noctx cmd.Stdout = nil cmd.Stderr = nil cmd.Stdin = nil @@ -4323,9 +4129,8 @@ func runDaemonStatus() { } return } - defer client.Close() - status, err := client.Status() + client.Close() //nolint:errcheck,gosec if err != nil { fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err) os.Exit(1) diff --git a/notify/notify.go b/notify/notify.go index 197db6d07e9e8a37aafc54248a3cbe31cb4cc371..a26787e7f773f61fddaac75c82f79d424107141b 100644 --- a/notify/notify.go +++ b/notify/notify.go @@ -12,9 +12,9 @@ func Send(title, body string) error { switch runtime.GOOS { case "darwin": script := fmt.Sprintf(`display notification %q with title %q sound name "default"`, body, title) - return exec.Command("osascript", "-e", script).Run() + return exec.Command("osascript", "-e", script).Run() //nolint:noctx case "linux": - return exec.Command("notify-send", title, body).Run() + return exec.Command("notify-send", title, body).Run() //nolint:noctx default: return nil } diff --git a/pgp/yubikey.go b/pgp/yubikey.go index 51dd53dc8bca1c602e628023117803f1b651ca60..3c780cc38be994973a0f68fe02a81f22f024b5df 100644 --- a/pgp/yubikey.go +++ b/pgp/yubikey.go @@ -34,17 +34,17 @@ func openCard() (*openpgp.Card, error) { "failed to connect to PC/SC daemon: %w\n"+ "Make sure pcscd is running:\n"+ " sudo systemctl enable --now pcscd.socket\n"+ - "You may also need the ccid package for USB smartcard support.", + "You may also need the ccid package for USB smartcard support", err, ) } pcscCard, err := pcsc.OpenFirstCard(ctx, filter.HasApplet(iso.AidOpenPGP), true) if err != nil { - ctx.Release() + ctx.Release() //nolint:errcheck,gosec return nil, fmt.Errorf( "no OpenPGP smartcard found: %w\n"+ - "Make sure your YubiKey is plugged in and has an OpenPGP key configured.", + "Make sure your YubiKey is plugged in and has an OpenPGP key configured", err, ) } @@ -52,8 +52,8 @@ func openCard() (*openpgp.Card, error) { isoCard := iso.NewCard(pcscCard) card, err := openpgp.NewCard(isoCard) if err != nil { - pcscCard.Close() - ctx.Release() + pcscCard.Close() //nolint:errcheck,gosec + ctx.Release() //nolint:errcheck,gosec return nil, fmt.Errorf("failed to initialize OpenPGP card: %w", err) } @@ -69,7 +69,7 @@ func BuildPGPSignedMessage(payload []byte, pin string, publicKeyPath string) ([] if err != nil { return nil, err } - defer card.Close() + defer card.Close() //nolint:errcheck // Verify PIN (PW1 for signing operations) if err := card.VerifyPassword(openpgp.PW1, pin); err != nil { @@ -231,7 +231,7 @@ func buildSignaturePacket(signedContent []byte, signer crypto.Signer, pubKey *pa body.WriteByte(digest[1]) // Encode the signature MPIs based on algorithm - switch pubKey.PubKeyAlgo { + switch pubKey.PubKeyAlgo { //nolint:exhaustive case packet.PubKeyAlgoEdDSA: // EdDSA: raw signature is r || s, 32 bytes each if len(rawSig) != 64 { @@ -293,7 +293,7 @@ func splitPayload(payload []byte) (headers, body []byte) { // buildSignedPart constructs the first MIME part content that gets hashed. // This must exactly match what appears between the boundary markers. -func buildSignedPart(headers, body []byte, boundary string) []byte { +func buildSignedPart(headers, body []byte, _ string) []byte { var originalContentType []byte if len(headers) > 0 { for _, line := range bytes.Split(headers, []byte("\r\n")) { @@ -395,8 +395,8 @@ func writeMPI(w io.Writer, data []byte) { bitLen := uint16((len(data)-1)*8 + bitLength(data[0])) buf := make([]byte, 2) binary.BigEndian.PutUint16(buf, bitLen) - w.Write(buf) //nolint:errcheck - w.Write(data) //nolint:errcheck + w.Write(buf) //nolint:errcheck,gosec + w.Write(data) //nolint:errcheck,gosec } // bitLength returns the number of significant bits in a byte. @@ -411,17 +411,18 @@ func bitLength(b byte) int { // writeNewFormatLength writes an OpenPGP new-format packet body length. func writeNewFormatLength(w *bytes.Buffer, length int) { - if length < 192 { + switch { + case length < 192: w.WriteByte(byte(length)) - } else if length < 8384 { + case length < 8384: length -= 192 w.WriteByte(byte(length>>8) + 192) w.WriteByte(byte(length)) - } else { + default: w.WriteByte(255) buf := make([]byte, 4) binary.BigEndian.PutUint32(buf, uint32(length)) - w.Write(buf) + _, _ = w.Write(buf) } } @@ -478,7 +479,7 @@ func VerifyYubiKeyAvailable() error { if err != nil { return err } - card.Close() + card.Close() //nolint:errcheck,gosec return nil } @@ -488,27 +489,30 @@ func GetYubiKeyInfo() (string, error) { if err != nil { return "", err } - defer card.Close() + defer card.Close() //nolint:errcheck var info strings.Builder - aid := card.ApplicationRelated.AID - info.WriteString(fmt.Sprintf("Manufacturer: %s\n", aid.Manufacturer)) - info.WriteString(fmt.Sprintf("Serial: %X\n", aid.Serial)) - info.WriteString(fmt.Sprintf("Version: %s\n", aid.Version)) + aid := card.AID + fmt.Fprintf(&info, "Manufacturer: %s\n", aid.Manufacturer) + fmt.Fprintf(&info, "Serial: %X\n", aid.Serial) + fmt.Fprintf(&info, "Version: %s\n", aid.Version) ch, err := card.GetCardholder() if err == nil && ch.Name != "" { - info.WriteString(fmt.Sprintf("Cardholder: %s\n", ch.Name)) + fmt.Fprintf(&info, "Cardholder: %s\n", ch.Name) } - if keys := card.ApplicationRelated.Keys; keys != nil { + if keys := card.Keys; keys != nil { if ki, ok := keys[openpgp.KeySign]; ok { - info.WriteString(fmt.Sprintf("Sign Key: %s", ki.AlgAttrs)) - if ki.Status == openpgp.KeyGenerated { + fmt.Fprintf(&info, "Sign Key: %s", ki.AlgAttrs) + switch ki.Status { + case openpgp.KeyGenerated: info.WriteString(" (generated)") - } else if ki.Status == openpgp.KeyImported { + case openpgp.KeyImported: info.WriteString(" (imported)") + case openpgp.KeyNotPresent: + // no key on card } info.WriteString("\n") } diff --git a/pgp/yubikey_test.go b/pgp/yubikey_test.go index b1e4bf114c33f40a11829cacf58acb0eed29d909..ae574194776a5704f59d06e8d1a7599b6595d4bd 100644 --- a/pgp/yubikey_test.go +++ b/pgp/yubikey_test.go @@ -37,7 +37,6 @@ func TestParseASN1Signature_TruncatedDoesNotPanic(t *testing.T) { } for _, tc := range cases { - tc := tc t.Run(tc.name, func(t *testing.T) { // The test must not panic: the fix replaces panics with errors. defer func() { diff --git a/plugin/api.go b/plugin/api.go index c9da983231df559590e99c95daa3fc18fe0e27ca..838a12691fa88a509d1bd4e7c1de31fe01ee11d5 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -36,7 +36,7 @@ func (m *Manager) registerAPI() { } // matcha.on(event, callback) — register a hook callback. -func (m *Manager) luaOn(L *lua.LState) int { +func (m *Manager) luaOn(L *lua.LState) int { //nolint:gocritic event := L.CheckString(1) fn := L.CheckFunction(2) m.registerHook(event, fn) @@ -44,7 +44,7 @@ func (m *Manager) luaOn(L *lua.LState) int { } // matcha.log(msg) — log a message to stderr. -func (m *Manager) luaLog(L *lua.LState) int { +func (m *Manager) luaLog(L *lua.LState) int { //nolint:gocritic msg := L.CheckString(1) log.Printf("[plugin] %s", msg) return 0 @@ -52,7 +52,7 @@ func (m *Manager) luaLog(L *lua.LState) int { // matcha.set_status(area, text) — set a persistent status string for a view area. // Valid areas: "inbox", "composer", "email_view". -func (m *Manager) luaSetStatus(L *lua.LState) int { +func (m *Manager) luaSetStatus(L *lua.LState) int { //nolint:gocritic area := L.CheckString(1) text := L.CheckString(2) m.statuses[area] = text @@ -61,7 +61,7 @@ func (m *Manager) luaSetStatus(L *lua.LState) int { // matcha.notify(msg [, seconds]) — show a temporary notification in the TUI. // The optional second argument sets the display duration in seconds (default 2). -func (m *Manager) luaNotify(L *lua.LState) int { +func (m *Manager) luaNotify(L *lua.LState) int { //nolint:gocritic m.pendingNotification = L.CheckString(1) m.pendingDuration = float64(L.OptNumber(2, 2)) return 0 @@ -69,7 +69,7 @@ func (m *Manager) luaNotify(L *lua.LState) int { // matcha.bind_key(key, area, description, callback) — register a custom keyboard shortcut. // Valid areas: "inbox", "email_view", "composer". -func (m *Manager) luaBindKey(L *lua.LState) int { +func (m *Manager) luaBindKey(L *lua.LState) int { //nolint:gocritic key := L.CheckString(1) area := L.CheckString(2) description := L.CheckString(3) @@ -102,7 +102,7 @@ func (m *Manager) luaBindKey(L *lua.LState) int { // return matcha.style(m, {color = "#ff0000", bold = true}) // end)) // end) -func (m *Manager) luaStyle(L *lua.LState) int { +func (m *Manager) luaStyle(L *lua.LState) int { //nolint:gocritic text := L.CheckString(1) opts := L.OptTable(2, nil) @@ -145,7 +145,7 @@ func (m *Manager) luaStyle(L *lua.LState) int { // plugin. spec is a table mapping setting key -> { type, default, label, // description }. Valid types: "boolean", "number", "string". Must be called // while the plugin file is being loaded (typically at the top level). -func (m *Manager) luaSettings(L *lua.LState) int { +func (m *Manager) luaSettings(L *lua.LState) int { //nolint:gocritic spec := L.CheckTable(1) return m.declareSettings(L, spec) } @@ -153,13 +153,13 @@ func (m *Manager) luaSettings(L *lua.LState) int { // matcha.get_setting(key [, plugin_name]) — return the current value of a // setting. The optional second argument allows reading another plugin's // setting; defaults to the current plugin when called during load. -func (m *Manager) luaGetSetting(L *lua.LState) int { +func (m *Manager) luaGetSetting(L *lua.LState) int { //nolint:gocritic return m.getSetting(L) } // matcha.mark_read(uid, account_id, folder) — queue a mark-as-read op for the given email. // The orchestrator dispatches the IMAP/backend call after the hook or keybinding returns. -func (m *Manager) luaMarkRead(L *lua.LState) int { +func (m *Manager) luaMarkRead(L *lua.LState) int { //nolint:gocritic uid := uint32(L.CheckInt(1)) accountID := L.CheckString(2) folder := L.CheckString(3) @@ -168,7 +168,7 @@ func (m *Manager) luaMarkRead(L *lua.LState) int { } // matcha.mark_unread(uid, account_id, folder) — queue a mark-as-unread op for the given email. -func (m *Manager) luaMarkUnread(L *lua.LState) int { +func (m *Manager) luaMarkUnread(L *lua.LState) int { //nolint:gocritic uid := uint32(L.CheckInt(1)) accountID := L.CheckString(2) folder := L.CheckString(3) @@ -178,14 +178,14 @@ func (m *Manager) luaMarkUnread(L *lua.LState) int { // matcha.suppress_auto_read() — prevent the currently viewed email from being // automatically marked as read. Must be called inside an email_viewed callback. -func (m *Manager) luaSuppressAutoRead(L *lua.LState) int { +func (m *Manager) luaSuppressAutoRead(L *lua.LState) int { //nolint:gocritic m.suppressAutoRead = true return 0 } // matcha.set_compose_field(field, value) — set a compose field value. // Valid fields: "to", "cc", "bcc", "subject", "body". -func (m *Manager) luaSetComposeField(L *lua.LState) int { +func (m *Manager) luaSetComposeField(L *lua.LState) int { //nolint:gocritic field := L.CheckString(1) value := L.CheckString(2) diff --git a/plugin/http.go b/plugin/http.go index e5f447f2b07fff7312c5b203bb8d2a6327e26b35..16a8bf766737e657aae5adddd8ed95adab0e50a9 100644 --- a/plugin/http.go +++ b/plugin/http.go @@ -25,7 +25,7 @@ var httpClient = httpclient.New(httpclient.PluginCallTimeout) // // Returns (response_table, nil) on success or (nil, error_string) on failure. // response_table has fields: status (number), body (string), headers (table). -func (m *Manager) luaHTTP(L *lua.LState) int { +func (m *Manager) luaHTTP(L *lua.LState) int { //nolint:gocritic opts := L.CheckTable(1) // URL (required). @@ -64,7 +64,7 @@ func (m *Manager) luaHTTP(L *lua.LState) int { bodyReader = strings.NewReader(v.String()) } - req, err := http.NewRequest(method, rawURL, bodyReader) + req, err := http.NewRequest(method, rawURL, bodyReader) //nolint:noctx if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) @@ -86,7 +86,7 @@ func (m *Manager) luaHTTP(L *lua.LState) int { L.Push(lua.LString(err.Error())) return 2 } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck body, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxBodySize)) if err != nil { diff --git a/plugin/http_test.go b/plugin/http_test.go index 9928eb70aa15272d9e80de18ae5bc08d6c670106..2353992573fa23d0f722c8f3415ff6a106d599b9 100644 --- a/plugin/http_test.go +++ b/plugin/http_test.go @@ -17,11 +17,11 @@ func newTestManager() *Manager { func TestHTTPGet(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { + if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } w.Header().Set("X-Test", "hello") - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) })) defer srv.Close() @@ -63,7 +63,7 @@ func TestHTTPGet(t *testing.T) { func TestHTTPPostWithBodyAndHeaders(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { + if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if ct := r.Header.Get("Content-Type"); ct != "application/json" { diff --git a/plugin/prompt.go b/plugin/prompt.go index b70123cc67be92778160449f1f717cf423bb1a53..217f57aa305831980f0a352d40ef6bb8f44c229d 100644 --- a/plugin/prompt.go +++ b/plugin/prompt.go @@ -15,7 +15,7 @@ type PendingPrompt struct { // luaPrompt implements matcha.prompt(placeholder, callback). // It requests a text input overlay in the TUI. When the user submits, // the callback is called with their input string. -func (m *Manager) luaPrompt(L *lua.LState) int { +func (m *Manager) luaPrompt(L *lua.LState) int { //nolint:gocritic placeholder := L.CheckString(1) fn := L.CheckFunction(2) diff --git a/plugin/settings.go b/plugin/settings.go index b4a2308571659794710cef25ecaff11df18f52d8..a4ef4ef0cb2429285c925952b621e6db6b494797 100644 --- a/plugin/settings.go +++ b/plugin/settings.go @@ -43,7 +43,7 @@ type PluginSettings struct { // matcha.on("email_received", function(email) // if cfg.enabled and #email.subject > cfg.threshold then ... end // end) -func (m *Manager) declareSettings(L *lua.LState, spec *lua.LTable) int { +func (m *Manager) declareSettings(L *lua.LState, spec *lua.LTable) int { //nolint:gocritic if m.currentPlugin == "" { L.RaiseError("matcha.settings() must be called from a plugin file") return 0 @@ -137,7 +137,7 @@ func (m *Manager) declareSettings(L *lua.LState, spec *lua.LTable) int { // load (e.g. inside a hook callback), it falls back to the plugin that owns // the running closure — for now we use currentPlugin and only allow lookups // to the plugin that declared the schema by name. -func (m *Manager) getSetting(L *lua.LState) int { +func (m *Manager) getSetting(L *lua.LState) int { //nolint:gocritic plugin := m.currentPlugin key := L.CheckString(1) @@ -177,7 +177,7 @@ func (m *Manager) lookupValue(plugin, key string, def SettingDef) interface{} { return def.Default } -func toLuaValue(L *lua.LState, v interface{}) lua.LValue { +func toLuaValue(_ *lua.LState, v interface{}) lua.LValue { switch x := v.(type) { case bool: return lua.LBool(x) diff --git a/plugin/storage.go b/plugin/storage.go index 12f866e2db9e77aabf0420de5ab93200b711e7cd..1d761f2393b1386c021e8d7b1a27d675a440ebcb 100644 --- a/plugin/storage.go +++ b/plugin/storage.go @@ -16,6 +16,10 @@ import ( var validPluginStoreName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +// ErrNoActivePlugin is returned when a storage operation is attempted without +// an active plugin context. +var ErrNoActivePlugin = errors.New("plugin: no active plugin") + type pluginStore struct { path string mu sync.Mutex @@ -75,14 +79,14 @@ func (s *pluginStore) flush() error { return err } tmpPath := tmp.Name() - defer os.Remove(tmpPath) + defer os.Remove(tmpPath) //nolint:errcheck if _, err := tmp.Write(raw); err != nil { - tmp.Close() + tmp.Close() //nolint:errcheck,gosec return err } if err := os.Chmod(tmpPath, 0o600); err != nil { - tmp.Close() + tmp.Close() //nolint:errcheck,gosec return err } if err := tmp.Close(); err != nil { @@ -131,7 +135,7 @@ func (s *pluginStore) Keys() []string { func (m *Manager) currentStore() (*pluginStore, error) { if m.currentPlugin == "" { - return nil, nil + return nil, ErrNoActivePlugin } if m.stores == nil { m.stores = make(map[string]*pluginStore) @@ -148,17 +152,17 @@ func (m *Manager) currentStore() (*pluginStore, error) { return s, nil } -func (m *Manager) luaStoreSet(L *lua.LState) int { +func (m *Manager) luaStoreSet(L *lua.LState) int { //nolint:gocritic key := L.CheckString(1) val := L.CheckString(2) s, err := m.currentStore() - if err != nil { - L.RaiseError("store_set: %v", err) + if errors.Is(err, ErrNoActivePlugin) { + L.RaiseError("store_set: no plugin context") return 0 } - if s == nil { - L.RaiseError("store_set: no plugin context") + if err != nil { + L.RaiseError("store_set: %v", err) return 0 } if err := s.Set(key, val); err != nil { @@ -167,18 +171,18 @@ func (m *Manager) luaStoreSet(L *lua.LState) int { return 0 } -func (m *Manager) luaStoreGet(L *lua.LState) int { +func (m *Manager) luaStoreGet(L *lua.LState) int { //nolint:gocritic key := L.CheckString(1) s, err := m.currentStore() + if errors.Is(err, ErrNoActivePlugin) { + L.Push(lua.LNil) + return 1 + } if err != nil { L.RaiseError("store_get: %v", err) return 0 } - if s == nil { - L.Push(lua.LNil) - return 1 - } if v, ok := s.Get(key); ok { L.Push(lua.LString(v)) } else { @@ -187,37 +191,33 @@ func (m *Manager) luaStoreGet(L *lua.LState) int { return 1 } -func (m *Manager) luaStoreDelete(L *lua.LState) int { +func (m *Manager) luaStoreDelete(L *lua.LState) int { //nolint:gocritic key := L.CheckString(1) s, err := m.currentStore() + if errors.Is(err, ErrNoActivePlugin) { + return 0 // silent no-op outside plugin context, matching store_get behavior + } if err != nil { L.RaiseError("store_delete: %v", err) return 0 } - // No plugin context: silently no-op, matching store_get's behavior so - // read+remove operations behave the same when called outside a plugin - // (e.g. from a non-plugin Lua chunk). store_set still raises so a - // missing-context write is surfaced loudly. - if s == nil { - return 0 - } if err := s.Delete(key); err != nil { L.RaiseError("store_delete: %v", err) } return 0 } -func (m *Manager) luaStoreKeys(L *lua.LState) int { +func (m *Manager) luaStoreKeys(L *lua.LState) int { //nolint:gocritic s, err := m.currentStore() + if errors.Is(err, ErrNoActivePlugin) { + L.Push(L.NewTable()) + return 1 + } if err != nil { L.RaiseError("store_keys: %v", err) return 0 } - if s == nil { - L.Push(L.NewTable()) - return 1 - } t := L.NewTable() for i, key := range s.Keys() { diff --git a/plugins/embed.go b/plugins/embed.go index 084e121db18e62420d46d606594d30043cacd0fc..263cb291292a9538eae1ad32fc33788c5803bf37 100644 --- a/plugins/embed.go +++ b/plugins/embed.go @@ -24,11 +24,11 @@ type PluginEntry struct { // FetchRegistry fetches the plugin registry from GitHub. func FetchRegistry() ([]PluginEntry, error) { client := httpclient.New(httpclient.RegistryFetchTimeout) - resp, err := client.Get(RegistryURL) + resp, err := client.Get(RegistryURL) //nolint:noctx if err != nil { return nil, fmt.Errorf("failed to fetch registry: %w", err) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("registry returned status %d", resp.StatusCode) @@ -55,11 +55,11 @@ func FetchPlugin(entry PluginEntry) ([]byte, error) { } client := httpclient.New(httpclient.RegistryFetchTimeout) - resp, err := client.Get(url) + resp, err := client.Get(url) //nolint:noctx if err != nil { return nil, fmt.Errorf("failed to fetch plugin: %w", err) } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("plugin download returned status %d", resp.StatusCode) diff --git a/screenshots/cmd/inbox_view/main.go b/screenshots/cmd/inbox_view/main.go index f027675501d48182aa6bb798a02c43eff464d809..6aebddd927ae2a135ca679319a2da9ebdc2d636c 100644 --- a/screenshots/cmd/inbox_view/main.go +++ b/screenshots/cmd/inbox_view/main.go @@ -14,6 +14,11 @@ import ( "github.com/floatpane/matcha/tui" ) +const ( + demoUserID = "demo-user" + demoUserEmail = "matcha@floatpane.com" +) + // wrapper forwards all messages to the FolderInbox and ensures it renders correctly. type wrapper struct { folderInbox *tui.FolderInbox @@ -42,10 +47,10 @@ func main() { accounts := []config.Account{ { - ID: "demo-user", + ID: demoUserID, Name: "Matcha Client", - Email: "matcha@floatpane.com", - FetchEmail: "matcha@floatpane.com", + Email: demoUserEmail, + FetchEmail: demoUserEmail, }, } @@ -53,20 +58,20 @@ func main() { { UID: 1012, From: "Alice Park ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "Quick sync on the API migration?", Date: now.Add(-12 * time.Minute), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1011, From: "GitHub ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "[floatpane/matcha] Fix: resolve inbox pagination issue (#281)", Date: now.Add(-47 * time.Minute), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1010, @@ -75,7 +80,7 @@ func main() { Subject: "New Dashboard Redesign - Preview & Feedback", Date: now.Add(-2 * time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, Attachments: []fetcher.Attachment{ {Filename: "dashboard-mockup.png", MIMEType: "image/png"}, }, @@ -83,29 +88,29 @@ func main() { { UID: 1009, From: "David Kim ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "Re: Quarterly budget review notes", Date: now.Add(-5 * time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1008, From: "Stripe ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "Your receipt from Acme Corp - Invoice #4821", Date: now.Add(-23 * time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1007, From: "Maria Gonzalez ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "Design system tokens - final version attached", Date: now.Add(-1*24*time.Hour - 6*time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, Attachments: []fetcher.Attachment{ {Filename: "design-tokens-v3.json", MIMEType: "application/json"}, }, @@ -113,56 +118,56 @@ func main() { { UID: 1006, From: "Linear ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "MAT-342: Implement keyboard shortcuts for compose view", Date: now.Add(-2*24*time.Hour - 3*time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1005, From: "James Wright ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "Onboarding docs are ready for review", Date: now.Add(-3*24*time.Hour - 1*time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1004, From: "Vercel ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "Deployment successful: matcha-docs-8f3a2b1", Date: now.Add(-4*24*time.Hour - 8*time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1003, From: "Lena Muller ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "Conference talk proposal - Rethinking TUI Design", Date: now.Add(-5*24*time.Hour - 2*time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1002, From: "GitHub ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "[floatpane/matcha] Release v1.4.0 published", Date: now.Add(-5*24*time.Hour - 14*time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, { UID: 1001, From: "Omar Hassan ", - To: []string{"matcha@floatpane.com"}, + To: []string{demoUserEmail}, Subject: "Re: Open source contribution guidelines", Date: now.Add(-6*24*time.Hour - 5*time.Hour), MessageID: "", - AccountID: "demo-user", + AccountID: demoUserID, }, } diff --git a/sender/sender.go b/sender/sender.go index e6cb3bbf6e6ce1d40d3a9c925439cfe6d823c628..db87055c4a5feb4f0f09022c06ff15255bd22662 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -128,7 +128,7 @@ func containsMarkup(body string) bool { doc := md.Parser().Parse(reader) var hasMarkup bool - ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { //nolint:errcheck,gosec if !entering { return ast.WalkContinue, nil } @@ -197,7 +197,7 @@ func writeQuotedPrintable(w io.Writer, body string) error { } // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments. -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, signSMIME bool, encryptSMIME bool, signPGP bool, encryptPGP bool) ([]byte, 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, signSMIME bool, encryptSMIME bool, signPGP bool, encryptPGP bool) ([]byte, error) { //nolint:gocyclo smtpServer := account.GetSMTPServer() smtpPort := account.GetSMTPPort() @@ -355,7 +355,6 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody msg.Write(encodedBody) } } - } else { // --- Non-plaintext path: build multipart/mixed with related/alternative, images and attachments --- var innerMsg bytes.Buffer @@ -371,7 +370,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody return nil, err } relatedWriter := multipart.NewWriter(relatedPartWriter) - relatedWriter.SetBoundary(relatedBoundary) + relatedWriter.SetBoundary(relatedBoundary) //nolint:errcheck,gosec // --- Alternative Part (text and html) --- altHeader := textproto.MIMEHeader{} @@ -382,7 +381,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody return nil, err } altWriter := multipart.NewWriter(altPartWriter) - altWriter.SetBoundary(altBoundary) + altWriter.SetBoundary(altBoundary) //nolint:errcheck,gosec // Plain text part textHeader := textproto.MIMEHeader{ @@ -410,7 +409,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody return nil, err } - altWriter.Close() // Finish the alternative part + altWriter.Close() //nolint:errcheck,gosec // --- Inline Images --- for cid, data := range images { @@ -432,10 +431,10 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody } // Encode raw image bytes to base64, then wrap at 76 chars per MIME rules encodedImg := base64.StdEncoding.EncodeToString(data) - imgPart.Write([]byte(clib.WrapBase64(encodedImg))) + imgPart.Write([]byte(clib.WrapBase64(encodedImg))) //nolint:errcheck,gosec } - relatedWriter.Close() // Finish the related part + relatedWriter.Close() //nolint:errcheck,gosec // --- Attachments --- for filename, data := range attachments { @@ -455,10 +454,10 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody } encodedData := base64.StdEncoding.EncodeToString(data) // MIME requires base64 to be line-wrapped at 76 characters - attachmentPart.Write([]byte(clib.WrapBase64(encodedData))) + attachmentPart.Write([]byte(clib.WrapBase64(encodedData))) //nolint:errcheck,gosec } - innerWriter.Close() // Finish the inner message + innerWriter.Close() //nolint:errcheck,gosec innerBodyBytes = append([]byte(innerHeaders), innerMsg.Bytes()...) @@ -641,13 +640,14 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody allRecipients = append(allRecipients, bcc...) var toEncrypt []byte - if len(pgpPayload) > 0 { + switch { + case len(pgpPayload) > 0: // Encrypt the signed message toEncrypt = pgpPayload - } else if len(payloadToEncrypt) > 0 { + case len(payloadToEncrypt) > 0: // Encrypt pre-prepared payload toEncrypt = payloadToEncrypt - } else { + default: // Encrypt what we've built so far toEncrypt = msg.Bytes() } @@ -670,7 +670,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody tlsConfig := &tls.Config{ ServerName: smtpServer, - InsecureSkipVerify: account.Insecure, + InsecureSkipVerify: account.Insecure, //nolint:gosec MinVersion: tls.VersionTLS12, ClientSessionCache: account.GetClientSessionCache(), VerifyConnection: func(cs tls.ConnectionState) error { @@ -684,13 +684,13 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody // Port 465 uses implicit TLS (the connection starts with TLS). // All other ports use plain TCP with optional STARTTLS upgrade. if smtpPort == 465 { - conn, err := tls.Dial("tcp", addr, tlsConfig) + conn, err := tls.Dial("tcp", addr, tlsConfig) //nolint:noctx if err != nil { return nil, err } c, err = smtp.NewClient(conn, smtpServer) if err != nil { - conn.Close() + conn.Close() //nolint:errcheck,gosec return nil, err } } else { @@ -700,7 +700,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody return nil, err } } - defer c.Close() + defer c.Close() //nolint:errcheck if err = c.Hello(smtpHelloHostname()); err != nil { return nil, err @@ -720,18 +720,19 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody if ok, mechs := c.Extension("AUTH"); ok { mechList := strings.ToUpper(mechs) - if account.IsOAuth2() { + switch { + case account.IsOAuth2(): // Use XOAUTH2 for OAuth2-enabled accounts token, tokenErr := config.GetOAuth2Token(account.Email) if tokenErr != nil { return nil, fmt.Errorf("oauth2: %w", tokenErr) } err = c.Auth(&xoauth2Auth{username: account.Email, token: token}) - } else if strings.Contains(mechList, "PLAIN") { + case strings.Contains(mechList, "PLAIN"): err = c.Auth(plainAuth) - } else if strings.Contains(mechList, "LOGIN") { + case strings.Contains(mechList, "LOGIN"): err = c.Auth(loginAuthFallback) - } else { + default: // Fall back to PLAIN and let the server decide err = c.Auth(plainAuth) } @@ -778,7 +779,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody // Google Calendar requires: // - multipart/alternative with text/plain + text/calendar; method=REPLY // - text/calendar part must NOT be Content-Disposition: attachment -func SendCalendarReply(account *config.Account, to []string, subject, plainBody string, icsData []byte, inReplyTo string, references []string) ([]byte, error) { +func SendCalendarReply(account *config.Account, to []string, subject, plainBody string, icsData []byte, inReplyTo string, references []string) ([]byte, error) { //nolint:gocyclo smtpServer := account.GetSMTPServer() smtpPort := account.GetSMTPPort() @@ -890,7 +891,7 @@ func SendCalendarReply(account *config.Account, to []string, subject, plainBody tlsConfig := &tls.Config{ ServerName: smtpServer, - InsecureSkipVerify: account.Insecure, + InsecureSkipVerify: account.Insecure, //nolint:gosec MinVersion: tls.VersionTLS12, ClientSessionCache: account.GetClientSessionCache(), VerifyConnection: func(cs tls.ConnectionState) error { @@ -902,13 +903,13 @@ func SendCalendarReply(account *config.Account, to []string, subject, plainBody var c *smtp.Client if smtpPort == 465 { - conn, err := tls.Dial("tcp", addr, tlsConfig) + conn, err := tls.Dial("tcp", addr, tlsConfig) //nolint:noctx if err != nil { return nil, err } c, err = smtp.NewClient(conn, smtpServer) if err != nil { - conn.Close() + conn.Close() //nolint:errcheck,gosec return nil, err } } else { @@ -918,7 +919,7 @@ func SendCalendarReply(account *config.Account, to []string, subject, plainBody return nil, err } } - defer c.Close() + defer c.Close() //nolint:errcheck if err = c.Hello(smtpHelloHostname()); err != nil { return nil, err @@ -934,17 +935,18 @@ func SendCalendarReply(account *config.Account, to []string, subject, plainBody if ok, mechs := c.Extension("AUTH"); ok { mechList := strings.ToUpper(mechs) - if account.IsOAuth2() { + switch { + case account.IsOAuth2(): token, tokenErr := config.GetOAuth2Token(account.Email) if tokenErr != nil { return nil, fmt.Errorf("oauth2: %w", tokenErr) } err = c.Auth(&xoauth2Auth{username: account.Email, token: token}) - } else if strings.Contains(mechList, "PLAIN") { + case strings.Contains(mechList, "PLAIN"): err = c.Auth(plainAuth) - } else if strings.Contains(mechList, "LOGIN") { + case strings.Contains(mechList, "LOGIN"): err = c.Auth(loginAuthFallback) - } else { + default: err = c.Auth(plainAuth) } if err != nil { @@ -1162,7 +1164,7 @@ func encryptEmailPGP(payload []byte, recipients []string, account *config.Accoun if entities == nil { entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey)) } - if entities != nil && len(entities) > 0 { + if len(entities) > 0 { entityList = append(entityList, entities[0]) } } diff --git a/tests/integration/imap_test.go b/tests/integration/imap_test.go index 353e29af889c98f8da93c0e767c4c0613d971dac..ba68a098eedd6786ce1d002158892d5ca5ab926c 100644 --- a/tests/integration/imap_test.go +++ b/tests/integration/imap_test.go @@ -72,7 +72,7 @@ func waitForGreenmail(t *testing.T, env testEnv) { deadline := time.Now().Add(60 * time.Second) url := fmt.Sprintf("http://%s:%d/api/configuration", env.host, env.apiPort) for time.Now().Before(deadline) { - resp, err := http.Get(url) //nolint:gosec + resp, err := http.Get(url) if err == nil && resp.StatusCode == http.StatusOK { resp.Body.Close() return diff --git a/theme/theme.go b/theme/theme.go index f83bb2c0f38e28919c47a255195980ef0d0cc4e9..0074ed4cc19eb99b14c4e6d9055b4f3d77115e9f 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -226,7 +226,7 @@ func SetTheme(name string) bool { // AllThemes returns all available themes (built-in + custom). func AllThemes() []Theme { - all := make([]Theme, len(BuiltinThemes)) + all := make([]Theme, len(BuiltinThemes)) //nolint:prealloc copy(all, BuiltinThemes) all = append(all, LoadCustomThemes()...) return all diff --git a/tui/choice.go b/tui/choice.go index df80fa97447e120f53ead73486b197b9e60a87b6..8b8b047c3f96d5298a2317c14e70ac5d0b8b630b 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -78,12 +78,12 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", kb.Global.NavUp: m.cursor = (m.cursor - 1 + len(m.choices)) % len(m.choices) - case "down", kb.Global.NavDown: + case keyDown, kb.Global.NavDown: m.cursor = (m.cursor + 1) % len(m.choices) - case "enter": + case keyEnter: // Use cursor index instead of string comparison idx := m.cursor - if idx == 0 { + if idx == 0 { //nolint:gocritic // Inbox return m, func() tea.Msg { return GoToInboxMsg{} } } else if idx == 1 { @@ -99,7 +99,6 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Settings return m, func() tea.Msg { return GoToSettingsMsg{} } } - } } diff --git a/tui/composer.go b/tui/composer.go index 8ad8db24a24818bc0260226e3e3eba9071e3b9d9..9ba552d3baf9d12ed8fae4cfd6bbf2f2428ef416 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -26,11 +26,9 @@ var ( var ( focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - noStyle = lipgloss.NewStyle() helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245")) - fromSelectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) smimeToggleStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245")) composerErrorStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("196")) ) @@ -214,7 +212,7 @@ func (m *Composer) hideComposerNotice() { m.noticeText = "" } -func (m *Composer) validateFromField() bool { +func (m *Composer) validateFromField() bool { //nolint:unparam if !m.isCatchAllAccount() { m.fromError = "" return true @@ -229,7 +227,7 @@ func (m *Composer) validateFromField() bool { return true } -func (m *Composer) validateEmailField(focus int) bool { +func (m *Composer) validateEmailField(focus int) bool { //nolint:unparam var input *textinput.Model var setError func(string) switch focus { @@ -412,7 +410,7 @@ func truncateSuggestionDisplay(s string, maxLen int) string { return string(runes[:maxLen-3]) + "..." } -func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo var cmds []tea.Cmd var cmd tea.Cmd @@ -478,22 +476,23 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedSuggestion-- } return m, nil - case "down", "ctrl+n": + case keyDown, "ctrl+n": if m.selectedSuggestion < len(m.suggestions)-1 { m.selectedSuggestion++ } return m, nil - case "tab", "enter": + case "tab", keyEnter: // Select the suggestion selected := m.suggestions[m.selectedSuggestion] var newEmail string - if len(selected.Addresses) > 0 { + switch { + case len(selected.Addresses) > 0: // Mailing list: emit just the addresses to maintain valid email formatting newEmail = strings.Join(selected.Addresses, ", ") - } else if selected.Name != "" && selected.Name != selected.Email { + case selected.Name != "" && selected.Name != selected.Email: newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email) - } else { + default: newEmail = selected.Email } @@ -535,7 +534,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle plugin prompt overlay if m.showPluginPrompt { switch msg.String() { - case "enter": + case keyEnter: value := m.pluginPromptInput.Value() m.showPluginPrompt = false return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} } @@ -556,12 +555,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedAccountIdx-- m.updateSignature() } - case "down", "j": + case keyDown, "j": if m.selectedAccountIdx < len(m.accounts)-1 { m.selectedAccountIdx++ m.updateSignature() } - case "enter": + case keyEnter: m.showAccountPicker = false case "esc": m.showAccountPicker = false @@ -583,7 +582,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.showNotice { switch msg.String() { - case "enter", "esc", " ": + case keyEnter, "esc", " ": m.hideComposerNotice() } return m, nil @@ -596,7 +595,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "up", kb.Global.NavUp: m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize return m, nil - case "down", kb.Global.NavDown: + case keyDown, kb.Global.NavDown: m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize return m, nil } @@ -672,10 +671,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - case "enter", " ": + case keyEnter, " ": switch m.focusIndex { case focusFrom: - if msg.String() == "enter" && len(m.accounts) > 1 { + if msg.String() == keyEnter && len(m.accounts) > 1 { m.showAccountPicker = true return m, nil } @@ -684,17 +683,17 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case focusAttachment: - if msg.String() == "enter" { + if msg.String() == keyEnter { return m, func() tea.Msg { return GoToFilePickerMsg{} } } case focusEncryptSMIME: - if msg.String() == "enter" || msg.String() == " " { + if msg.String() == keyEnter || msg.String() == " " { m.encryptSMIME = !m.encryptSMIME } return m, nil case focusSend: - if msg.String() == "enter" { + if msg.String() == keyEnter { if !m.canSendEmail() { return m, m.showComposerNotice(t("composer.invalid_email_fields")) } @@ -798,21 +797,21 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *Composer) View() tea.View { +func (m *Composer) View() tea.View { //nolint:gocyclo var composerView strings.Builder var button string ck := config.Keybinds.Composer if m.focusIndex == focusSend { - button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]") + button = focusedStyle.Render("[ " + t("composer.send") + " ]") } else { - button = blurredStyle.Copy().Render("[ " + t("composer.send") + " ]") + button = blurredStyle.Render("[ " + t("composer.send") + " ]") } // From field with account selector fromAddr := m.getFromAddress() var fromField string - if m.isCatchAllAccount() { + if m.isCatchAllAccount() { //nolint:gocritic fromAddrView := m.fromInput.View() if len(m.accounts) > 1 { if m.focusIndex == focusFrom { diff --git a/tui/constants.go b/tui/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..15952efbe26c5d3572125bbffd0a5b9634f2248d --- /dev/null +++ b/tui/constants.go @@ -0,0 +1,11 @@ +package tui + +const ( + keyEnter = "enter" + keyDown = "down" + keyRight = "right" + keyCount = "count" + keyINBOX = "INBOX" + keyYubikey = "yubikey" + keyShiftTab = "shift+tab" +) diff --git a/tui/email_view.go b/tui/email_view.go index c21a2b667877b4efb5348f9241c3962443b3c0cc..826c26569108f9a597e13faaaef7767b23af87bb 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -20,8 +20,8 @@ import ( // ClearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout. func ClearKittyGraphics() { // Delete all images: a=d (action=delete), d=A (delete all) - os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\") - os.Stdout.Sync() + os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\") //nolint:errcheck,gosec + os.Stdout.Sync() //nolint:errcheck,gosec } var ( @@ -80,7 +80,7 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma var originalICSData []byte for _, att := range email.Attachments { - if att.Filename == "smime-status.internal" { + if att.Filename == "smime-status.internal" { //nolint:gocritic isSMIME = att.IsSMIMESignature || att.IsSMIMEEncrypted smimeTrusted = att.SMIMEVerified isEncrypted = att.IsSMIMEEncrypted @@ -187,7 +187,7 @@ func (m *EmailView) Init() tea.Cmd { func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - var cmds []tea.Cmd + cmds := make([]tea.Cmd, 0, 1) switch msg := msg.(type) { case tea.KeyPressMsg: @@ -210,12 +210,12 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.attachmentCursor = (m.attachmentCursor - 1 + len(m.email.Attachments)) % len(m.email.Attachments) } return m, nil - case "down", kb.Global.NavDown: + case keyDown, kb.Global.NavDown: if len(m.email.Attachments) > 0 { m.attachmentCursor = (m.attachmentCursor + 1) % len(m.email.Attachments) } return m, nil - case "enter": + case keyEnter: if len(m.email.Attachments) > 0 { selected := m.email.Attachments[m.attachmentCursor] idx := m.emailIndex @@ -340,8 +340,8 @@ func (m *EmailView) View() tea.View { // before re-rendering to prevent stacking on scroll. Uses d=a (delete all // placements) instead of d=A (delete all including data) so that images // can be re-displayed by ID without re-uploading. - os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\") - os.Stdout.Sync() + os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\") //nolint:errcheck,gosec + os.Stdout.Sync() //nolint:errcheck,gosec var cryptoStatus strings.Builder @@ -501,18 +501,18 @@ func renderCalendarInvite(event *calendar.Event) string { var b strings.Builder b.WriteString("📅 Meeting Invite\n\n") - b.WriteString(fmt.Sprintf("Title: %s\n", event.Summary)) - b.WriteString(fmt.Sprintf("When: %s\n", formatEventTime(event.Start, event.End))) + fmt.Fprintf(&b, "Title: %s\n", event.Summary) + fmt.Fprintf(&b, "When: %s\n", formatEventTime(event.Start, event.End)) if event.Location != "" { - b.WriteString(fmt.Sprintf("Where: %s\n", event.Location)) + fmt.Fprintf(&b, "Where: %s\n", event.Location) } - b.WriteString(fmt.Sprintf("Organizer: %s\n", event.Organizer)) + fmt.Fprintf(&b, "Organizer: %s\n", event.Organizer) if event.Description != "" { desc := truncateString(event.Description, 100) - b.WriteString(fmt.Sprintf("\n%s\n", desc)) + fmt.Fprintf(&b, "\n%s\n", desc) } b.WriteString("\n") diff --git a/tui/filepicker.go b/tui/filepicker.go index f401c525121e1d6351bd7d0e5316da3f5ea8f2f6..5c17b82b34736c2fce03ab1fb04cbf0757353a15 100644 --- a/tui/filepicker.go +++ b/tui/filepicker.go @@ -90,7 +90,7 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Path input mode if m.editingPath { switch msg.String() { - case "enter": + case keyEnter: path := m.pathInput.Value() if path == "" { m.editingPath = false @@ -135,7 +135,7 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.items) > 0 { m.cursor = (m.cursor - 1 + len(m.items)) % len(m.items) } - case "down", "j": + case keyDown, "j": if len(m.items) > 0 { m.cursor = (m.cursor + 1) % len(m.items) } @@ -151,7 +151,7 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "h": m.showHidden = !m.showHidden m.readDir() - case "enter": + case keyEnter: if len(m.items) == 0 { return m, nil } @@ -183,7 +183,7 @@ func (m *FilePicker) View() tea.View { var b strings.Builder b.WriteString(titleStyle.Render("Select a File") + "\n") - b.WriteString(fmt.Sprintf(" %s\n", m.currentPath)) + fmt.Fprintf(&b, " %s\n", m.currentPath) if m.editingPath { b.WriteString(m.pathInput.View() + "\n") diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 86c3d49eba924268e998ee53b302fdd8a1325625..a6760fb4bb28439cec48328e8d29a22ec86eb7f9 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "sort" "strings" @@ -118,10 +117,10 @@ func sortFolders(folders []string) []string { sort.SliceStable(sorted, func(i, j int) bool { iUpper := strings.ToUpper(sorted[i]) jUpper := strings.ToUpper(sorted[j]) - if iUpper == "INBOX" { + if iUpper == keyINBOX { return true } - if jUpper == "INBOX" { + if jUpper == keyINBOX { return false } return sorted[i] < sorted[j] @@ -159,7 +158,7 @@ func (m *FolderInbox) SetDisableImages(v bool) { // NewFolderInbox creates a new FolderInbox with the given folders and accounts. func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox { folders = sortFolders(folders) - currentFolder := "INBOX" + currentFolder := keyINBOX if len(folders) > 0 { currentFolder = folders[0] } @@ -182,7 +181,7 @@ func (m *FolderInbox) Init() tea.Cmd { return nil } -func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo // If move overlay is active, handle its input if m.movingEmail { return m.updateMoveOverlay(msg) @@ -260,18 +259,17 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.moveSourceFolder = m.currentFolder return m, nil - } else { - // Single move - selectedItem, ok := m.inbox.list.SelectedItem().(item) - if ok { - m.movingEmail = true - m.moveTargetIdx = 0 - m.moveUID = selectedItem.uid - m.moveUIDs = []uint32{selectedItem.uid} - m.moveAccountID = selectedItem.accountID - m.moveSourceFolder = m.currentFolder - return m, nil - } + } + // Single move + selectedItem, ok := m.inbox.list.SelectedItem().(item) + if ok { + m.movingEmail = true + m.moveTargetIdx = 0 + m.moveUID = selectedItem.uid + m.moveUIDs = []uint32{selectedItem.uid} + m.moveAccountID = selectedItem.accountID + m.moveSourceFolder = m.currentFolder + return m, nil } } @@ -417,8 +415,7 @@ func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd { func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { kb := config.Keybinds - switch msg := msg.(type) { - case tea.KeyPressMsg: + if msg, ok := msg.(tea.KeyPressMsg); ok { switch msg.String() { case kb.Global.Cancel: m.movingEmail = false @@ -429,14 +426,14 @@ func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { m.moveTargetIdx = len(m.moveFolderChoices()) - 1 } return m, nil - case "down", kb.Global.NavDown: + case keyDown, kb.Global.NavDown: m.moveTargetIdx++ choices := m.moveFolderChoices() if m.moveTargetIdx >= len(choices) { m.moveTargetIdx = 0 } return m, nil - case "enter": + case keyEnter: choices := m.moveFolderChoices() if len(choices) > 0 && m.moveTargetIdx < len(choices) { destFolder := choices[m.moveTargetIdx] @@ -461,15 +458,14 @@ func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { DestFolder: destFolder, } } - } else { - // Single move - return m, func() tea.Msg { - return MoveEmailToFolderMsg{ - UID: m.moveUID, - AccountID: m.moveAccountID, - SourceFolder: m.moveSourceFolder, - DestFolder: destFolder, - } + } + // Single move + return m, func() tea.Msg { + return MoveEmailToFolderMsg{ + UID: m.moveUID, + AccountID: m.moveAccountID, + SourceFolder: m.moveSourceFolder, + DestFolder: destFolder, } } } @@ -511,17 +507,18 @@ func (m *FolderInbox) View() tea.View { var content string - if m.previewPane != nil { + switch { + case m.previewPane != nil: // Three-pane layout: folders | inbox | email preview inboxPane := m.renderInboxPane() previewPane := m.renderPreviewPane() content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, previewPane) - } else if m.previewedUID != 0 { + case m.previewedUID != 0: // Split pane loading state (body being fetched) inboxPane := m.renderInboxPane() emptyPreview := m.renderEmptyPreview() content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, emptyPreview) - } else { + default: // Two-pane layout (original): folders | inbox inboxView := m.inbox.View().Content content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView) @@ -594,7 +591,7 @@ func (m *FolderInbox) renderWithMoveOverlay(content string) string { title := t("folder_inbox.move_to_folder") if len(m.moveUIDs) > 1 { title = tn("folder_inbox.move_multiple", len(m.moveUIDs), map[string]interface{}{ - "count": len(m.moveUIDs), + keyCount: len(m.moveUIDs), }) } b.WriteString(moveOverlayTitleStyle.Render(title)) @@ -743,11 +740,6 @@ func (m *FolderInbox) GetFolders() []string { return m.folders } -// Helper to get the formatted inbox title -func folderInboxTitle(folder string) string { - return fmt.Sprintf("Folder: %s", folder) -} - // renderInboxPane renders inbox with border for split pane mode func (m *FolderInbox) renderInboxPane() string { inboxWidth := m.calculateInboxWidth() diff --git a/tui/folder_inbox_test.go b/tui/folder_inbox_test.go index 25e88ae32a1a38c5ed3c2a0802f29ca19bbf4509..0b9104bb3e6353111af7c3c48c585c0d2a0b74a5 100644 --- a/tui/folder_inbox_test.go +++ b/tui/folder_inbox_test.go @@ -17,7 +17,7 @@ func TestFolderInboxSplitPreviewRendersSearchHit(t *testing.T) { accounts := []config.Account{ {ID: "account-1", Email: "host.example.com", FetchEmail: "first@example.com"}, } - fi := NewFolderInbox([]string{"INBOX", "Archive"}, accounts) + fi := NewFolderInbox([]string{keyINBOX, "Archive"}, accounts) // Force a non-zero canvas so calculate*Width does not panic on Update. model, _ := fi.Update(tea.WindowSizeMsg{Width: 200, Height: 60}) fi = model.(*FolderInbox) @@ -68,7 +68,7 @@ func TestFolderInboxSplitPreviewPrefersAllEmails(t *testing.T) { accounts := []config.Account{ {ID: "account-1", Email: "host.example.com", FetchEmail: "first@example.com"}, } - fi := NewFolderInbox([]string{"INBOX"}, accounts) + fi := NewFolderInbox([]string{keyINBOX}, accounts) model, _ := fi.Update(tea.WindowSizeMsg{Width: 200, Height: 60}) fi = model.(*FolderInbox) @@ -96,7 +96,7 @@ func TestSearchOverlayKeysNotIntercepted(t *testing.T) { accounts := []config.Account{ {ID: "account-1", Email: "host.example.com", FetchEmail: "first@example.com"}, } - fi := NewFolderInbox([]string{"INBOX", "Archive"}, accounts) + fi := NewFolderInbox([]string{keyINBOX, "Archive"}, accounts) model, _ := fi.Update(tea.WindowSizeMsg{Width: 200, Height: 60}) fi = model.(*FolderInbox) diff --git a/tui/inbox.go b/tui/inbox.go index 8d63edd53e7e40c1ca29a30efaefb90eff36a012..e80a1851e9892a954a42e89146c972845dcda209 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -26,7 +26,6 @@ var ( tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1) ) -var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) var visualSelectedStyle lipgloss.Style @@ -73,7 +72,7 @@ type itemDelegate struct { func (d itemDelegate) Height() int { return 1 } func (d itemDelegate) Spacing() int { return 0 } func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { //nolint:gocyclo i, ok := listItem.(item) if !ok { return @@ -114,7 +113,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list listWidth := m.Width() - 2 // account for PaddingLeft(2) in itemStyle isSelected := index == m.Index() - styledDate := dateStyle.Render(dateStr) + var styledDate string if isSelected { styledDate = selectedDateStyle.Render(dateStr) } else { @@ -214,7 +213,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } } - fmt.Fprint(w, fn(str+strings.Repeat(" ", padding)+styledDate)) + fmt.Fprint(w, fn(str+strings.Repeat(" ", padding)+styledDate)) //nolint:errcheck } // formatInboxDate formats a time as relative unless detailed dates are enabled @@ -236,13 +235,13 @@ func formatInboxDate(timestamp time.Time, layout string, detailedDates bool) str return t("time.just_now") case d < time.Hour: mins := int(d.Minutes()) - return tn("time.minute_ago", mins, map[string]interface{}{"count": mins}) + return tn("time.minute_ago", mins, map[string]interface{}{keyCount: mins}) case d < 24*time.Hour: hours := int(d.Hours()) - return tn("time.hour_ago", hours, map[string]interface{}{"count": hours}) + return tn("time.hour_ago", hours, map[string]interface{}{keyCount: hours}) case d < 7*24*time.Hour: days := int(d.Hours() / 24) - return tn("time.day_ago", days, map[string]interface{}{"count": days}) + return tn("time.day_ago", days, map[string]interface{}{keyCount: days}) default: return formatAbsoluteDate(timestamp, layout, now) } @@ -378,11 +377,11 @@ func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox { // Build tabs: empty for single account, "ALL" + accounts for multiple - var tabs []AccountTab + tabs := make([]AccountTab, 0, 1+len(accounts)) if len(accounts) <= 1 { tabs = []AccountTab{{ID: "", Label: "", Email: ""}} } else { - tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}} + tabs = append(tabs, AccountTab{ID: "", Label: "ALL", Email: ""}) for _, acc := range accounts { // Use FetchEmail for display, fall back to Email if not set displayEmail := accountDisplayEmail(acc) @@ -718,11 +717,12 @@ func (m *Inbox) itemForEmail(email fetcher.Email, index int, showAccountLabel bo func (m *Inbox) getTitle() string { var title string - if m.searchActive { + switch { + case m.searchActive: title = fmt.Sprintf("Search Results - %s", m.searchQuery) - } else if m.currentAccountID == "" { + case m.currentAccountID == "": title = m.getBaseTitle() + " - " + t("inbox.all_accounts") - } else { + default: title = m.getBaseTitle() for _, acc := range m.accounts { if acc.ID == m.currentAccountID { @@ -761,9 +761,10 @@ func (m *Inbox) getBaseTitle() string { return "Trash" case MailboxArchive: return "Archive" - default: + case MailboxInbox: return "Inbox" } + return "Inbox" } func (m *Inbox) folderKey() string { @@ -821,7 +822,7 @@ func (m *Inbox) Init() tea.Cmd { return nil } -func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo var cmds []tea.Cmd switch msg := msg.(type) { @@ -891,7 +892,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateListTitle() return m, nil } - case kb.Global.NavDown, "down", kb.Global.NavUp, "up": + case kb.Global.NavDown, keyDown, kb.Global.NavUp, "up": if m.visualMode { // Let the list handle navigation first var cmd tea.Cmd @@ -914,7 +915,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateList() return m, nil } - case "right", kb.Inbox.NextTab: + case keyRight, kb.Inbox.NextTab: if len(m.tabs) > 1 { m.activeTabIndex++ if m.activeTabIndex >= len(m.tabs) { @@ -948,13 +949,12 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox} } - } else { - // Single delete - selectedItem, ok := m.list.SelectedItem().(item) - if ok && selectedItem.uid != 0 { - return m, func() tea.Msg { - return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} - } + } + // Single delete + selectedItem, ok := m.list.SelectedItem().(item) + if ok && selectedItem.uid != 0 { + return m, func() tea.Msg { + return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} } } case kb.Inbox.Archive: @@ -977,13 +977,12 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox} } - } else { - // Single archive - selectedItem, ok := m.list.SelectedItem().(item) - if ok && selectedItem.uid != 0 { - return m, func() tea.Msg { - return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} - } + } + // Single archive + selectedItem, ok := m.list.SelectedItem().(item) + if ok && selectedItem.uid != 0 { + return m, func() tea.Msg { + return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} } } case kb.Inbox.Refresh: @@ -1388,7 +1387,7 @@ func (m *Inbox) RemoveEmails(uids []uint32, accountID string) { // Remove from all emails list var filteredAll []fetcher.Email for _, e := range m.allEmails { - if !(uidSet[e.UID] && e.AccountID == accountID) { + if !uidSet[e.UID] || e.AccountID != accountID { filteredAll = append(filteredAll, e) } } @@ -1413,7 +1412,7 @@ func (m *Inbox) RemoveEmail(uid uint32, accountID string) { // Remove from all emails list var filteredAll []fetcher.Email for _, e := range m.allEmails { - if !(e.UID == uid && e.AccountID == accountID) { + if e.UID != uid || e.AccountID != accountID { filteredAll = append(filteredAll, e) } } @@ -1454,11 +1453,11 @@ func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) { m.noMoreByAccount = make(map[string]bool) // Rebuild tabs: empty for single account, "ALL" + accounts for multiple - var tabs []AccountTab + tabs := make([]AccountTab, 0, 1+len(accounts)) if len(accounts) <= 1 { tabs = []AccountTab{{ID: "", Label: "", Email: ""}} } else { - tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}} + tabs = append(tabs, AccountTab{ID: "", Label: "ALL", Email: ""}) for _, acc := range accounts { displayEmail := accountDisplayEmail(acc) tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail}) diff --git a/tui/login.go b/tui/login.go index efa8c3ad0cf3cd5f55e494b1e629b8e817d38b68..0c3bfe08ac228eec2d364abd01f7a700a9fa35a3 100644 --- a/tui/login.go +++ b/tui/login.go @@ -199,7 +199,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - case "enter": + case keyEnter: m.updateFlags() visible := m.visibleFields() lastField := visible[len(visible)-1] @@ -209,7 +209,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } fallthrough - case "tab", "shift+tab", "up", "down": + case "tab", keyShiftTab, "up", keyDown: s := msg.String() m.updateFlags() visible := m.visibleFields() @@ -223,7 +223,7 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - if s == "up" || s == "shift+tab" { + if s == "up" || s == keyShiftTab { curPos-- } else { curPos++ diff --git a/tui/mailing_list.go b/tui/mailing_list.go index aebead1e9bd60a026c26ee939d4736a1a7789222..9caffd3b9e66660662be81b10bb4a56b54206265 100644 --- a/tui/mailing_list.go +++ b/tui/mailing_list.go @@ -73,7 +73,7 @@ func (m *MailingListEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case kb.Global.Cancel: return m, func() tea.Msg { return GoToSettingsMsg{} } - case "tab", "shift+tab", "up", "down": + case "tab", keyShiftTab, "up", keyDown: if m.focus == 0 { m.focus = 1 m.nameInput.Blur() @@ -84,24 +84,23 @@ func (m *MailingListEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.nameInput.Focus() } return m, nil - case "enter": + case keyEnter: if m.focus == 0 { m.focus = 1 m.nameInput.Blur() m.addrInput.Focus() return m, nil - } else { - // Submit on second field - name := strings.TrimSpace(m.nameInput.Value()) - addrs := strings.TrimSpace(m.addrInput.Value()) - if name != "" && addrs != "" { - editIdx := m.editIndex - return m, func() tea.Msg { - return SaveMailingListMsg{ - Name: name, - Addresses: addrs, - EditIndex: editIdx, - } + } + // Submit on second field + name := strings.TrimSpace(m.nameInput.Value()) + addrs := strings.TrimSpace(m.addrInput.Value()) + if name != "" && addrs != "" { + editIdx := m.editIndex + return m, func() tea.Msg { + return SaveMailingListMsg{ + Name: name, + Addresses: addrs, + EditIndex: editIdx, } } } diff --git a/tui/marketplace.go b/tui/marketplace.go index 353c5dbf9242993cdfee877408f4aec71b00e9f4..f7d5b769f61c7a313bee26191281be83aaf8b21c 100644 --- a/tui/marketplace.go +++ b/tui/marketplace.go @@ -142,7 +142,7 @@ func (m Marketplace) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.offset = m.cursor } } - case "down", kb.Global.NavDown: + case keyDown, kb.Global.NavDown: if m.cursor < len(m.entries)-1 { m.cursor++ visible := m.visibleRows() @@ -150,7 +150,7 @@ func (m Marketplace) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.offset = m.cursor - visible + 1 } } - case "enter": + case keyEnter: if m.cursor < len(m.entries) { entry := m.entries[m.cursor] if m.installed[entry.Name] { @@ -187,7 +187,7 @@ func installPlugin(entry plugins.PluginEntry) tea.Cmd { } dir := filepath.Join(home, ".config", "matcha", "plugins") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0750); err != nil { return PluginInstalledMsg{Name: entry.Name, Err: err} } @@ -257,12 +257,12 @@ func (m Marketplace) View() tea.View { name += " " + mpInstalledStyle.Render("[installed]") } - b.WriteString(fmt.Sprintf("%s%s\n", cursor, name)) - b.WriteString(fmt.Sprintf(" %s\n", mpItemDescStyle.Render(entry.Description))) + fmt.Fprintf(&b, "%s%s\n", cursor, name) + fmt.Fprintf(&b, " %s\n", mpItemDescStyle.Render(entry.Description)) } if len(m.entries) > visible { - b.WriteString(fmt.Sprintf("\n %d/%d plugins", m.cursor+1, len(m.entries))) + fmt.Fprintf(&b, "\n %d/%d plugins", m.cursor+1, len(m.entries)) } } diff --git a/tui/password_prompt.go b/tui/password_prompt.go index c083ca3185fa755bb01e191c1d21fb958528163c..aab0ff48e4eb284e8d955ffe95ca83bcfc1fceb6 100644 --- a/tui/password_prompt.go +++ b/tui/password_prompt.go @@ -47,7 +47,7 @@ func (m *PasswordPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch msg.String() { - case "enter": + case keyEnter: password := m.input.Value() if password == "" { m.err = t("password_prompt.error_empty") @@ -72,7 +72,6 @@ func (m *PasswordPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Password correct — key is in msg.Key return m, nil - } var cmd tea.Cmd diff --git a/tui/search.go b/tui/search.go index f143d36b8f0a122783d905c582eb748ca7504cf7..5b7d25e494b78f037b69cc5e53aa8d2a16a55dbe 100644 --- a/tui/search.go +++ b/tui/search.go @@ -52,8 +52,7 @@ func (o *SearchOverlay) Update(msg tea.Msg, mailbox MailboxKind, accountID strin o.results = msg.Emails return nil case tea.KeyPressMsg: - switch msg.String() { - case "enter": + if msg.String() == keyEnter { if o.loading { return nil } diff --git a/tui/settings.go b/tui/settings.go index affa34a6dbaee78341aeacdf0a3f108d1eaf1973..84c8eb02a642ffb2d35f4e267af49ed682af01b1 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -197,9 +197,9 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Global shortcut to return to menu from content pane if m.activePane == PaneContent && msg.String() == "esc" { // unless we are in crypto config or encryption editing which have their own esc logic - if !(m.activeCategory == CategoryAccounts && m.isCryptoConfig) && - !(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) && - !(m.activeCategory == CategoryPlugins && (m.pluginEditing || m.pluginSelected != "")) { + if (m.activeCategory != CategoryAccounts || !m.isCryptoConfig) && + (m.activeCategory != CategoryEncryption || m.encFocusIndex <= -1) && + (m.activeCategory != CategoryPlugins || (!m.pluginEditing && m.pluginSelected == "")) { m.activePane = PaneMenu return m, nil } @@ -207,21 +207,20 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.activePane == PaneMenu { return m.updateMenu(msg) - } else { - switch m.activeCategory { - case CategoryGeneral: - return m.updateGeneral(msg) - case CategoryAccounts: - return m.updateAccounts(msg) - case CategoryTheme: - return m.updateTheme(msg) - case CategoryMailingLists: - return m.updateMailingLists(msg) - case CategoryEncryption: - return m.updateEncryption(msg) - case CategoryPlugins: - return m.updatePlugins(msg) - } + } + switch m.activeCategory { + case CategoryGeneral: + return m.updateGeneral(msg) + case CategoryAccounts: + return m.updateAccounts(msg) + case CategoryTheme: + return m.updateTheme(msg) + case CategoryMailingLists: + return m.updateMailingLists(msg) + case CategoryEncryption: + return m.updateEncryption(msg) + case CategoryPlugins: + return m.updatePlugins(msg) } case SecureModeEnabledMsg: @@ -245,12 +244,13 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update text inputs if active if m.activePane == PaneContent { - if m.activeCategory == CategoryEncryption { + switch { + case m.activeCategory == CategoryEncryption: m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) cmds = append(cmds, cmd) m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) cmds = append(cmds, cmd) - } else if m.activeCategory == CategoryAccounts && m.isCryptoConfig { + case m.activeCategory == CategoryAccounts && m.isCryptoConfig: m.smimeCertInput, cmd = m.smimeCertInput.Update(msg) cmds = append(cmds, cmd) m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg) @@ -261,7 +261,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.pgpPINInput, cmd = m.pgpPINInput.Update(msg) cmds = append(cmds, cmd) - } else if m.activeCategory == CategoryPlugins && m.pluginEditing { + case m.activeCategory == CategoryPlugins && m.pluginEditing: m.pluginInput, cmd = m.pluginInput.Update(msg) cmds = append(cmds, cmd) } @@ -276,9 +276,9 @@ func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", "k": m.menuCursor = (m.menuCursor - 1 + categoryCount) % categoryCount - case "down", "j": + case keyDown, "j": m.menuCursor = (m.menuCursor + 1) % categoryCount - case "right", "l", "enter": + case keyRight, "l", keyEnter: m.activeCategory = SettingsCategory(m.menuCursor) m.activePane = PaneContent diff --git a/tui/settings_accounts.go b/tui/settings_accounts.go index 697f8b71b71238ee38aad779d03b40132fae7bfb..3734466dc5e2e26785f989e5dc4f8163c9b447c7 100644 --- a/tui/settings_accounts.go +++ b/tui/settings_accounts.go @@ -39,7 +39,7 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "up", "k": itemCount := len(m.cfg.Accounts) + 1 m.accountsCursor = (m.accountsCursor - 1 + itemCount) % itemCount - case "down", "j": + case keyDown, "j": itemCount := len(m.cfg.Accounts) + 1 m.accountsCursor = (m.accountsCursor + 1) % itemCount case "d": @@ -80,7 +80,7 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.enterCryptoConfig() return m, textinput.Blink } - case "enter": + case keyEnter: if m.accountsCursor == len(m.cfg.Accounts) { return m, func() tea.Msg { return GoToAddAccountMsg{} } } else if m.accountsCursor < len(m.cfg.Accounts) { diff --git a/tui/settings_crypto.go b/tui/settings_crypto.go index baf1acec0b8574e205c3d166a3eab0866cbd90ca..d15ee6c1e945cac3c5ed317e9964be2dc63876a1 100644 --- a/tui/settings_crypto.go +++ b/tui/settings_crypto.go @@ -44,8 +44,8 @@ func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { case "esc": m.isCryptoConfig = false return m, nil - case "tab", "shift+tab", "up", "down": - if msg.String() == "shift+tab" || msg.String() == "up" { + case "tab", keyShiftTab, "up", keyDown: + if msg.String() == keyShiftTab || msg.String() == "up" { m.cryptoFocusIndex-- if m.cryptoFocusIndex < 0 { m.cryptoFocusIndex = cryptoConfigMaxFocus @@ -56,8 +56,8 @@ func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { m.cryptoFocusIndex = 0 } } - if m.cryptoFocusIndex == 6 && m.pgpKeySource != "yubikey" { - if msg.String() == "shift+tab" || msg.String() == "up" { + if m.cryptoFocusIndex == 6 && m.pgpKeySource != keyYubikey { + if msg.String() == keyShiftTab || msg.String() == "up" { m.cryptoFocusIndex = 5 } else { m.cryptoFocusIndex = 7 @@ -85,7 +85,7 @@ func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { default: // advance to next next := m.cryptoFocusIndex + 1 - if next == 6 && m.pgpKeySource != "yubikey" { + if next == 6 && m.pgpKeySource != keyYubikey { next = 7 } cmds = append(cmds, setFocus(next)) @@ -100,7 +100,7 @@ func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { return m, nil case 5: if m.pgpKeySource == "file" { - m.pgpKeySource = "yubikey" + m.pgpKeySource = keyYubikey } else { m.pgpKeySource = "file" } @@ -144,12 +144,12 @@ func (m *Settings) viewSMIMEConfig() string { renderField(4, "Private Key Path:", m.pgpPrivateKeyInput.View()) keySource := "File" - if m.pgpKeySource == "yubikey" { + if m.pgpKeySource == keyYubikey { keySource = "YubiKey" } renderField(5, "Key Source:", keySource) - if m.pgpKeySource == "yubikey" { + if m.pgpKeySource == keyYubikey { renderField(6, "YubiKey PIN:", m.pgpPINInput.View()) } diff --git a/tui/settings_encryption.go b/tui/settings_encryption.go index 3d0a0410a30ba592b5d3de80aefef33cea43c0f0..2190a5d1331a898df5479b4491fb424076e1244d 100644 --- a/tui/settings_encryption.go +++ b/tui/settings_encryption.go @@ -28,7 +28,7 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return m, nil } - if msg.String() == "enter" { + if msg.String() == keyEnter { m.confirmingDisable = true } return m, nil @@ -45,8 +45,8 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.encError = "" m.activePane = PaneMenu return m, nil - case "tab", "shift+tab", "down", "up": - if msg.String() == "shift+tab" || msg.String() == "up" { + case "tab", keyShiftTab, keyDown, "up": + if msg.String() == keyShiftTab || msg.String() == "up" { m.encFocusIndex-- if m.encFocusIndex < 0 { m.encFocusIndex = 2 @@ -67,7 +67,7 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.encConfirmInput.Focus()) } return m, tea.Batch(cmds...) - case "enter": + case keyEnter: switch m.encFocusIndex { case 0: m.encFocusIndex = 1 @@ -99,13 +99,14 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { default: // Forward input to focused textinput var cmd tea.Cmd - if m.encFocusIndex == 0 { + switch m.encFocusIndex { + case 0: before := m.encPasswordInput.Value() m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) if m.encPasswordInput.Value() != before { m.handlePasswordChanged() } - } else if m.encFocusIndex == 1 { + case 1: m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) } return m, cmd @@ -206,7 +207,8 @@ func (m *Settings) renderPasswordStrength() string { return successStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_strong")) case passwordstrength.Medium: return settingsFocusedStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_medium")) - default: + case passwordstrength.Weak: return dangerStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_weak")) } + return dangerStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_weak")) } diff --git a/tui/settings_general.go b/tui/settings_general.go index bf7d4a7f0705921edc2ee303202a08d8a12c4f85..9b0605addb6ee027dffeb6b03013d9ad0448455f 100644 --- a/tui/settings_general.go +++ b/tui/settings_general.go @@ -18,7 +18,8 @@ type generalOption struct { } func (m *Settings) buildGeneralOptions() []generalOption { - opts := []generalOption{ + opts := make([]generalOption, 0, 9+len(m.cfg.Accounts)) + opts = append(opts, []generalOption{ {"settings_general.disable_images", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails.", false, ""}, {"settings_general.hide_tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen.", false, ""}, {"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail.", false, ""}, @@ -28,7 +29,7 @@ func (m *Settings) buildGeneralOptions() []generalOption { {"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed.", false, ""}, {"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Changes apply instantly.", false, ""}, {"settings_general.signature", getSignatureStatus(), "Configure the global signature appended to your outgoing emails.", false, ""}, - } + }...) for _, acc := range m.cfg.Accounts { status := t("settings_general.signature_not_configured") @@ -54,13 +55,13 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", "k": m.generalCursor = (m.generalCursor - 1 + len(opts)) % len(opts) - case "down", "j": + case keyDown, "j": m.generalCursor = (m.generalCursor + 1) % len(opts) - case "enter", "space", "right", "l": + case keyEnter, "space", keyRight, "l": if m.generalCursor < len(opts) { opt := opts[m.generalCursor] if opt.isAccountSig { - if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" { + if msg.String() == keyEnter || msg.String() == keyRight || msg.String() == "l" { return m, func() tea.Msg { return GoToSignatureEditorMsg{AccountID: opt.accountID} } } return m, nil @@ -118,14 +119,14 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.cfg.Language = langs[nextIdx] _ = config.SaveConfig(m.cfg) // Apply language change immediately - i18n.GetManager().SetLanguage(m.cfg.Language) + i18n.GetManager().SetLanguage(m.cfg.Language) //nolint:errcheck,gosec // Trigger full UI rebuild return m, tea.Batch( func() tea.Msg { return ConfigSavedMsg{} }, func() tea.Msg { return LanguageChangedMsg{} }, ) case 8: // Edit Signature - if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" { + if msg.String() == keyEnter || msg.String() == keyRight || msg.String() == "l" { return m, func() tea.Msg { return GoToSignatureEditorMsg{} } } } diff --git a/tui/settings_lists.go b/tui/settings_lists.go index cdd01098df254621642d1388908a70cdd034e3bb..724736fcb90de8c13c20953261fb16eb16a43556 100644 --- a/tui/settings_lists.go +++ b/tui/settings_lists.go @@ -32,7 +32,7 @@ func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) case "up", "k": itemCount := len(m.cfg.MailingLists) + 1 m.listsCursor = (m.listsCursor - 1 + itemCount) % itemCount - case "down", "j": + case keyDown, "j": itemCount := len(m.cfg.MailingLists) + 1 m.listsCursor = (m.listsCursor + 1) % itemCount case "d": @@ -51,7 +51,7 @@ func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) } } } - case "enter": + case keyEnter: if m.listsCursor == len(m.cfg.MailingLists) { return m, func() tea.Msg { return GoToAddMailingListMsg{} } } @@ -70,7 +70,7 @@ func (m *Settings) viewMailingLists() string { for i, list := range m.cfg.MailingLists { addrCount := tn("settings_mailing_lists.address_count", len(list.Addresses), map[string]interface{}{ - "count": len(list.Addresses), + keyCount: len(list.Addresses), }) line := fmt.Sprintf("%s - %s", list.Name, accountEmailStyle.Render(addrCount)) diff --git a/tui/settings_plugins.go b/tui/settings_plugins.go index 418e062332bd2e090ff1293965f50f39757bd874..ecb883b9bbedf1c88677d6567540d01c3348e4c6 100644 --- a/tui/settings_plugins.go +++ b/tui/settings_plugins.go @@ -43,12 +43,12 @@ func (m *Settings) updatePluginList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { kb := config.Keybinds.Global key := msg.String() - switch { - case key == "up" || key == kb.NavUp: + switch key { + case "up", kb.NavUp: m.pluginListCursor = (m.pluginListCursor - 1 + len(schemas)) % len(schemas) - case key == "down" || key == kb.NavDown: + case keyDown, kb.NavDown: m.pluginListCursor = (m.pluginListCursor + 1) % len(schemas) - case key == "enter" || key == "right" || key == "l": + case keyEnter, keyRight, "l": m.pluginSelected = schemas[m.pluginListCursor].Plugin m.pluginSettingCursor = 0 } @@ -64,15 +64,15 @@ func (m *Settings) updatePluginSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd kb := config.Keybinds.Global key := msg.String() - switch { - case key == "esc" || key == "left" || key == "h" || key == kb.Cancel: + switch key { + case "esc", "left", "h", kb.Cancel: m.pluginSelected = "" return m, nil - case key == "up" || key == kb.NavUp: + case "up", kb.NavUp: m.pluginSettingCursor = (m.pluginSettingCursor - 1 + len(defs)) % len(defs) - case key == "down" || key == kb.NavDown: + case keyDown, kb.NavDown: m.pluginSettingCursor = (m.pluginSettingCursor + 1) % len(defs) - case key == "enter" || key == "space" || key == "right" || key == "l": + case keyEnter, "space", keyRight, "l": def := defs[m.pluginSettingCursor] switch def.Type { case plugin.SettingBool: @@ -103,7 +103,7 @@ func (m *Settings) updatePluginEditor(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) m.pluginEditing = false m.pluginInput.Blur() return m, nil - case "enter": + case keyEnter: raw := m.pluginInput.Value() switch m.pluginEditingType { case plugin.SettingNumber: @@ -114,6 +114,8 @@ func (m *Settings) updatePluginEditor(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) m.plugins.SetSettingValue(m.pluginSelected, m.pluginEditingKey, n) case plugin.SettingString: m.plugins.SetSettingValue(m.pluginSelected, m.pluginEditingKey, raw) + case plugin.SettingBool: + // Bool settings are toggled directly, not via text input } m.pluginEditing = false m.pluginInput.Blur() diff --git a/tui/settings_theme.go b/tui/settings_theme.go index bcc4715407b7939e66d10729ad520639d49ff50d..9a8fef443cec1993a8178068a7084ff681dc4111 100644 --- a/tui/settings_theme.go +++ b/tui/settings_theme.go @@ -17,11 +17,11 @@ func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if len(themes) > 0 { m.themeCursor = (m.themeCursor - 1 + len(themes)) % len(themes) } - case "down", "j": + case keyDown, "j": if len(themes) > 0 { m.themeCursor = (m.themeCursor + 1) % len(themes) } - case "enter", "space", "right", "l": + case keyEnter, "space", keyRight, "l": if m.themeCursor < len(themes) { selected := themes[m.themeCursor] theme.SetTheme(selected.Name) diff --git a/tui/signature.go b/tui/signature.go index 29557b2cfc68a02232034bae3f9f2ea055bc9b01..2c2c98ad9182f3b7ccaf33643a9fb6379f1e9921 100644 --- a/tui/signature.go +++ b/tui/signature.go @@ -65,9 +65,9 @@ func (m *SignatureEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Save and go back to settings signature := m.textarea.Value() if m.accountID != "" { - go config.SaveSignatureForAccount(m.accountID, signature) + go config.SaveSignatureForAccount(m.accountID, signature) //nolint:errcheck } else { - go config.SaveSignature(signature) + go config.SaveSignature(signature) //nolint:errcheck } return m, func() tea.Msg { return GoToSettingsMsg{} } } diff --git a/tui/theme.go b/tui/theme.go index bbbbc20bb8a0990b169fe28d928b2b945471b644..12eacaeeb67d7a0a7b53d370f7921ac6594ce5a2 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -59,18 +59,15 @@ func RebuildStyles() { suggestionBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(t.Secondary).Padding(0, 1) focusedStyle = lipgloss.NewStyle().Foreground(t.Accent) blurredStyle = lipgloss.NewStyle().Foreground(t.Secondary) - noStyle = lipgloss.NewStyle() helpStyle = lipgloss.NewStyle().Foreground(t.SubtleText) emailRecipientStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(t.Secondary) - fromSelectorStyle = lipgloss.NewStyle().Foreground(t.Accent) smimeToggleStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(t.Secondary) // inbox.go tabStyle = lipgloss.NewStyle().Padding(0, 2) activeTabStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(t.Accent).Bold(true).Underline(true) tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1) - dateStyle = lipgloss.NewStyle().Foreground(t.MutedText) unreadEmailStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) readEmailStyle = lipgloss.NewStyle().Foreground(t.Secondary) visualSelectedStyle = lipgloss.NewStyle().Background(t.AccentDark).Foreground(t.AccentText).PaddingLeft(2) diff --git a/view/html.go b/view/html.go index fce56661d361ad530d467a9cc0093effdfafadce..9071182b3e8988916af0cc679ff69237502c4549 100644 --- a/view/html.go +++ b/view/html.go @@ -19,6 +19,8 @@ import ( lru "github.com/hashicorp/golang-lru/v2" ) +const termGhostty = "ghostty" + func linkStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link) } @@ -40,7 +42,7 @@ func getTerminalCellSize() int { // Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea) if tty, err := os.Open("/dev/tty"); err == nil { - defer tty.Close() + defer tty.Close() //nolint:errcheck if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 { return cellHeight } @@ -57,7 +59,7 @@ func hyperlinkSupported() bool { // Terminals known to support OSC 8 hyperlinks supportedTerms := []string{ "kitty", - "ghostty", + termGhostty, "wezterm", "alacritty", "foot", @@ -77,7 +79,7 @@ func hyperlinkSupported() bool { "iterm.app", "hyper", "vscode", - "ghostty", + termGhostty, "wezterm", } @@ -114,13 +116,12 @@ func hyperlink(url, text string) string { if supported { // Use OSC 8 hyperlink sequence for supported terminals return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text)) - } else { - // Fallback to plain text format for unsupported terminals - if text == url { - return fmt.Sprintf("<%s>", linkStyle().Render(url)) - } - return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url)) } + // Fallback to plain text format for unsupported terminals + if text == url { + return fmt.Sprintf("<%s>", linkStyle().Render(url)) + } + return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url)) } func decodeQuotedPrintable(s string) (string, error) { @@ -148,12 +149,12 @@ func kittySupported() bool { func ghosttySupported() bool { // Check for TERM containing ghostty term := strings.ToLower(os.Getenv("TERM")) - if strings.Contains(term, "ghostty") { + if strings.Contains(term, termGhostty) { return true } // Check for Ghostty-specific environment variables - if os.Getenv("TERM_PROGRAM") == "ghostty" { + if os.Getenv("TERM_PROGRAM") == termGhostty { return true } @@ -187,11 +188,7 @@ func weztermSupported() bool { } term := strings.ToLower(os.Getenv("TERM")) - if strings.Contains(term, "wezterm") { - return true - } - - return false + return strings.Contains(term, "wezterm") } func waystSupported() bool { @@ -201,11 +198,7 @@ func waystSupported() bool { } termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM")) - if termProgram == "wayst" { - return true - } - - return false + return termProgram == "wayst" } func warpSupported() bool { @@ -229,11 +222,7 @@ func konsoleSupported() bool { } termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM")) - if termProgram == "konsole" { - return true - } - - return false + return termProgram == "konsole" } func zellijSupported() bool { @@ -276,12 +265,12 @@ func debugImageProtocol(format string, args ...interface{}) { msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...) loglevel.Infof("%s", strings.TrimSuffix(msg, "\n")) if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" { - if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { + if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { //nolint:gosec _, _ = f.WriteString(msg) _ = f.Close() } } else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" { - if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { + if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { //nolint:gosec _, _ = f.WriteString(msg) _ = f.Close() } @@ -326,7 +315,7 @@ func fetchRemoteBase64(url string) string { debugImageProtocol("remote fetch failed url=%s err=%v", url, err) return "" } - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode < 200 || resp.StatusCode >= 300 { debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode) return "" @@ -369,103 +358,6 @@ func dataURIBase64(uri string) string { const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:" const imageRowPlaceholderSuffix = "]]" -func kittyInlineImage(payload string) string { - if payload == "" { - return "" - } - - const chunkSize = 4096 - var b strings.Builder - - // Calculate how many terminal rows the image occupies to advance text after it. - rows := 1 - if data, err := base64.StdEncoding.DecodeString(payload); err == nil { - if _, h, ok := clib.ImageDimensions(data); ok { - cellHeight := getTerminalCellSize() - rows = (h + cellHeight - 1) / cellHeight - if rows < 1 { - rows = 1 - } - debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows) - } - } - - for offset := 0; offset < len(payload); offset += chunkSize { - end := offset + chunkSize - if end > len(payload) { - end = len(payload) - } - more := "0" - if end < len(payload) { - more = "1" - } - - chunk := payload[offset:end] - if offset == 0 { - // C=1 means cursor does NOT move after image render (stays at top-left of image position) - // This is needed for proper TUI rendering, but we must add newlines to push text below - b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk)) - } else { - b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk)) - } - } - - // Add newlines to push cursor below the image. - // Use a placeholder that won't be collapsed by the newline regex. - b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)) - - return b.String() -} - -// iterm2InlineImage renders an image using iTerm2's image protocol -func iterm2InlineImage(payload string) string { - if payload == "" { - return "" - } - - // Calculate rows for cursor positioning - rows := 1 - if data, err := base64.StdEncoding.DecodeString(payload); err == nil { - if _, h, ok := clib.ImageDimensions(data); ok { - cellHeight := getTerminalCellSize() - rows = (h + cellHeight - 1) / cellHeight - if rows < 1 { - rows = 1 - } - debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows) - } - } - - // iTerm2 image protocol: ESC]1337;File=inline=1:BEL - result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload) - - // Add placeholder for row spacing - result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix) - - return result -} - -// sixelInlineImage returns Sixel escape sequence + newline placeholders -func sixelInlineImage(base64PNG string) string { - data, err := base64.StdEncoding.DecodeString(base64PNG) - if err != nil { - return "" - } - - cellHeight := getTerminalCellSize() - sixel, rows, err := clib.EncodePNGToSixel(data, cellHeight) - if err != nil { - debugImageProtocol("Sixel encoding failed: %v", err) - return "" - } - - debugImageProtocol("Sixel: encoded %d bytes, %d rows", len(sixel), rows) - - // Sixel sequences don't auto-advance cursor - // Add newlines to preserve layout - return sixel + strings.Repeat("\n", rows) -} - // sixelImageEscapeOnly returns raw Sixel for out-of-band rendering func sixelImageEscapeOnly(base64PNG string) string { data, err := base64.StdEncoding.DecodeString(base64PNG) @@ -482,28 +374,6 @@ func sixelImageEscapeOnly(base64PNG string) string { return sixel } -// renderInlineImage renders an image using the appropriate protocol for the detected terminal -func renderInlineImage(payload string) string { - if payload == "" { - return "" - } - - // Priority: Sixel in multiplexers overrides native protocols - if sixelSupported() { - return sixelInlineImage(payload) - } - - if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() { - // These terminals use the Kitty graphics protocol - return kittyInlineImage(payload) - } else if iterm2Supported() || warpSupported() { - // iTerm2 and Warp use the iTerm2 image protocol - return iterm2InlineImage(payload) - } - - return "" -} - // imageRows calculates the number of terminal rows an image occupies. func imageRows(payload string) int { rows := 1 @@ -546,12 +416,12 @@ func kittyUploadImage(payload string, id uint32) { if offset == 0 { // a=t: transmit (upload) only, don't display yet // i=ID: assign this image ID - fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk) + fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk) //nolint:errcheck } else { - fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk) + fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk) //nolint:errcheck } } - os.Stdout.Sync() + os.Stdout.Sync() //nolint:errcheck,gosec } // kittyDisplayImage displays a previously uploaded image by its ID at the @@ -602,9 +472,9 @@ func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ... debugImageProtocol("Sixel: rendering %d bytes at row=%d col=%d", len(placement.SixelEncoded), screenRow+1, col) // Position cursor + render Sixel - fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", + fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", //nolint:errcheck screenRow+1, col, placement.SixelEncoded) - os.Stdout.Sync() + os.Stdout.Sync() //nolint:errcheck,gosec return } @@ -619,12 +489,12 @@ func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ... placement.Uploaded = true } seq := kittyDisplayImage(placement.ID) - fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq) - os.Stdout.Sync() + fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq) //nolint:errcheck + os.Stdout.Sync() //nolint:errcheck,gosec } else if useIterm2 { seq := iterm2ImageEscapeOnly(placement.Base64) - fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq) - os.Stdout.Sync() + fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq) //nolint:errcheck + os.Stdout.Sync() //nolint:errcheck,gosec } } @@ -773,9 +643,10 @@ func renderHTMLToText(htmlBody []byte, inline map[string]string, h1Style, h2Styl if !disableImages && imageProtocolSupported() { var payload string - if strings.HasPrefix(src, "data:image/") { + switch { + case strings.HasPrefix(src, "data:image/"): payload = dataURIBase64(src) - } else if strings.HasPrefix(src, "cid:") { + case strings.HasPrefix(src, "cid:"): cid := strings.TrimPrefix(src, "cid:") cid = strings.Trim(cid, "<>") if inline != nil { @@ -784,7 +655,7 @@ func renderHTMLToText(htmlBody []byte, inline map[string]string, h1Style, h2Styl } else { debugImageProtocol("cid lookup skipped inline map nil for %s", cid) } - } else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") { + case strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://"): payload = fetchRemoteBase64(src) } @@ -800,22 +671,22 @@ func renderHTMLToText(htmlBody []byte, inline map[string]string, h1Style, h2Styl rows int }{idx, payload, rows}) - text.WriteString(fmt.Sprintf("\n[[MATCHA_IMG:%d]]", idx)) - text.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)) + fmt.Fprintf(&text, "\n[[MATCHA_IMG:%d]]", idx) + fmt.Fprintf(&text, "\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix) continue } debugImageProtocol("no payload for src=%s", src) } if hyperlinkSupported() { - text.WriteString(fmt.Sprintf("\n %s \n", hyperlink(src, fmt.Sprintf("[Click here to view image: %s]", alt)))) + fmt.Fprintf(&text, "\n %s \n", hyperlink(src, fmt.Sprintf("[Click here to view image: %s]", alt))) } else { - text.WriteString(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src)))) + fmt.Fprintf(&text, "\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))) } case clib.HElemTable: headerRows := 0 if elem.Attr1 != "" { - fmt.Sscanf(elem.Attr1, "%d", &headerRows) + fmt.Sscanf(elem.Attr1, "%d", &headerRows) //nolint:errcheck,gosec } text.WriteString("\n") text.WriteString(renderTable(elem.Text, headerRows)) @@ -855,7 +726,7 @@ func renderHTMLToText(htmlBody []byte, inline map[string]string, h1Style, h2Styl for lineNum, line := range lines { if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil { var idx int - fmt.Sscanf(matches[1], "%d", &idx) + fmt.Sscanf(matches[1], "%d", &idx) //nolint:errcheck,gosec for _, pi := range pendingImages { if pi.index == idx { placements = append(placements, ImagePlacement{ @@ -1026,7 +897,7 @@ func styleQuotedReplies(text string) string { } // Check if line starts with ">" (quoted text) - if strings.HasPrefix(trimmedLine, ">") { + if strings.HasPrefix(trimmedLine, ">") { //nolint:gocritic if !inQuote { // Start a new quote block without header info inQuote = true @@ -1039,7 +910,7 @@ func styleQuotedReplies(text string) string { quoteBlock = append(quoteBlock, quotedContent) } else if inQuote { // End of quote block - check if it's just whitespace - if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") { + if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") { //nolint:gocritic // Empty line within quote block, keep it quoteBlock = append(quoteBlock, "") } else if trimmedLine == "" && len(quoteBlock) == 0 { @@ -1102,11 +973,12 @@ func renderQuoteBox(from, date string, lines []string) string { // Build header with email on left and date on right var header string if from != "" || date != "" { - if from != "" && date != "" { + switch { + case from != "" && date != "": header = quoteHeaderStyle().Render(from + " " + date) - } else if from != "" { + case from != "": header = quoteHeaderStyle().Render(from) - } else { + default: header = quoteHeaderStyle().Render(date) } }