Detailed changes
@@ -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
@@ -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 {
@@ -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)
}
}
@@ -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 {
@@ -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,
)
@@ -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
@@ -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)
}
}
@@ -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
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
@@ -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)
@@ -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 {
@@ -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))
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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
@@ -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)
@@ -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)
}
@@ -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.
@@ -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
@@ -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)
}
}
@@ -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()
@@ -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)
@@ -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.
@@ -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
}
@@ -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)
}
@@ -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
@@ -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
@@ -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 {
@@ -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
}
}
@@ -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
@@ -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)
-}
@@ -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
}
@@ -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)
@@ -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 != "" {
@@ -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()})
@@ -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)
}
}
}
@@ -11,7 +11,7 @@ func TestBuildThreeMessageChain(t *testing.T) {
threads := Build([]EmailHeader{
{ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1", Sender: "a"},
{ID: "<b@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2", Sender: "b"},
- {ID: "<c@example>", References: []string{"<a@example>", "<b@example>"}, Subject: "Re: Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3", Sender: "c"},
+ {ID: "<c@example>", References: []string{"<a@example>", "<b@example>"}, 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",
@@ -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 <email>" 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)
@@ -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
}
@@ -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")
}
@@ -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() {
@@ -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)
@@ -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 {
@@ -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" {
@@ -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)
@@ -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)
@@ -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() {
@@ -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)
@@ -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 <alice.park@example.com>",
- To: []string{"matcha@floatpane.com"},
+ To: []string{demoUserEmail},
Subject: "Quick sync on the API migration?",
Date: now.Add(-12 * time.Minute),
MessageID: "<api-migration-012@example.com>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
{
UID: 1011,
From: "GitHub <notifications@github.com>",
- To: []string{"matcha@floatpane.com"},
+ To: []string{demoUserEmail},
Subject: "[floatpane/matcha] Fix: resolve inbox pagination issue (#281)",
Date: now.Add(-47 * time.Minute),
MessageID: "<gh-notif-281@github.com>",
- 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: "<dashboard-redesign-001@example.com>",
- 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 <david.kim@example.com>",
- To: []string{"matcha@floatpane.com"},
+ To: []string{demoUserEmail},
Subject: "Re: Quarterly budget review notes",
Date: now.Add(-5 * time.Hour),
MessageID: "<budget-review-009@example.com>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
{
UID: 1008,
From: "Stripe <receipts@stripe.com>",
- To: []string{"matcha@floatpane.com"},
+ To: []string{demoUserEmail},
Subject: "Your receipt from Acme Corp - Invoice #4821",
Date: now.Add(-23 * time.Hour),
MessageID: "<stripe-receipt-4821@stripe.com>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
{
UID: 1007,
From: "Maria Gonzalez <maria.g@example.com>",
- 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: "<design-tokens-007@example.com>",
- 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 <notifications@linear.app>",
- 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: "<linear-342@linear.app>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
{
UID: 1005,
From: "James Wright <j.wright@example.com>",
- 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: "<onboarding-005@example.com>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
{
UID: 1004,
From: "Vercel <notifications@vercel.com>",
- 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: "<vercel-deploy-004@vercel.com>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
{
UID: 1003,
From: "Lena Muller <lena.m@example.com>",
- 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: "<conference-003@example.com>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
{
UID: 1002,
From: "GitHub <notifications@github.com>",
- 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: "<gh-release-140@github.com>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
{
UID: 1001,
From: "Omar Hassan <omar.h@example.com>",
- 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: "<oss-contrib-001@example.com>",
- AccountID: "demo-user",
+ AccountID: demoUserID,
},
}
@@ -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])
}
}
@@ -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
@@ -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
@@ -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{} }
}
-
}
}
@@ -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 {
@@ -0,0 +1,11 @@
+package tui
+
+const (
+ keyEnter = "enter"
+ keyDown = "down"
+ keyRight = "right"
+ keyCount = "count"
+ keyINBOX = "INBOX"
+ keyYubikey = "yubikey"
+ keyShiftTab = "shift+tab"
+)
@@ -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")
@@ -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")
@@ -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()
@@ -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)
@@ -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})
@@ -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++
@@ -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,
}
}
}
@@ -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))
}
}
@@ -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
@@ -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
}
@@ -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
@@ -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) {
@@ -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())
}
@@ -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"))
}
@@ -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{} }
}
}
@@ -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))
@@ -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()
@@ -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)
@@ -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{} }
}
@@ -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)
@@ -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:<base64_data>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)
}
}