From edbe3c3666410e1e3a57b63411c6bf50411c27e2 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 16 Dec 2025 21:19:19 +0400 Subject: [PATCH 1/2] feat: version check and "matcha update" --- .github/workflows/release.yml | 6 +- .gitignore | 3 +- main.go | 325 +++++++++++++++++++++++++++++++++- tui/choice.go | 51 +++++- 4 files changed, 373 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15d19b5..ab75e3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,7 @@ name: Release on: - workflow_run: - workflows: ["Go CI"] - types: [completed] - branches: - - master + workflow_dispatch: permissions: contents: write # to create releases, tags and upload assets diff --git a/.gitignore b/.gitignore index c14a297..f2eb52b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -go.sum \ No newline at end of file +go.sum +matcha diff --git a/main.go b/main.go index e0ea8b4..5fd9bde 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,15 @@ package main import ( + "archive/tar" "bytes" + "compress/gzip" "encoding/base64" + "encoding/json" "fmt" + "io" "log" + "net/http" "os" "os/exec" "path/filepath" @@ -29,6 +34,29 @@ const ( paginationLimit = 20 ) +// Version variables are injected by the build (GoReleaser ldflags). +// They default to "dev" when not set by the build system. +var ( + version = "dev" + commit = "" + date = "" +) + +// UpdateAvailableMsg is sent into the TUI when a newer release is detected. +type UpdateAvailableMsg struct { + Latest string + Current string +} + +// internal struct for parsing GitHub release JSON. +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + type mainModel struct { current tea.Model previousModel tea.Model @@ -56,7 +84,7 @@ func newInitialModel(cfg *config.Config) *mainModel { } func (m *mainModel) Init() tea.Cmd { - return m.current.Init() + return tea.Batch(m.current.Init(), checkForUpdatesCmd()) } func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -963,7 +991,302 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download } } +/* +detectInstalledVersion returns a best-effort installed version string. +Priority: + 1. If the build-in `version` variable is set to something other than "dev", return it. + 2. If Homebrew is present and reports a version for `matcha`, return that. + 3. If snap is present and lists `matcha`, return that. + 4. Fallback to the build `version` (likely "dev"). +*/ +func detectInstalledVersion() string { + v := strings.TrimSpace(version) + if v != "dev" && v != "" { + return v + } + + // Try Homebrew (macOS) + if runtime.GOOS == "darwin" { + 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 { + parts := strings.Fields(string(out)) + if len(parts) >= 2 { + return parts[1] + } + } + } + } + + // 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 { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) >= 2 { + fields := strings.Fields(lines[1]) + if len(fields) >= 2 { + return fields[1] + } + } + } + } + } + + return v +} + +/* +checkForUpdatesCmd queries GitHub for the latest release tag and returns a +tea.Msg (UpdateAvailableMsg) if the latest version differs from the current +installed version. This runs in the background when the TUI initializes. +*/ +func checkForUpdatesCmd() tea.Cmd { + return func() tea.Msg { + // Non-fatal: if anything goes wrong we just don't show the update message. + const api = "https://api.github.com/repos/floatpane/matcha/releases/latest" + resp, err := http.Get(api) + if err != nil { + return nil + } + defer resp.Body.Close() + + var rel githubRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil + } + + latest := strings.TrimPrefix(rel.TagName, "v") + installed := strings.TrimPrefix(detectInstalledVersion(), "v") + if latest != "" && installed != "" && latest != installed { + return UpdateAvailableMsg{Latest: latest, Current: installed} + } + return nil + } +} + +// runUpdateCLI implements the CLI entrypoint for `matcha update`. +// It detects the likely installation method and attempts the appropriate +// update path (Homebrew, Snap, or GitHub release binary extract). +func runUpdateCLI() error { + const api = "https://api.github.com/repos/floatpane/matcha/releases/latest" + resp, err := http.Get(api) + if err != nil { + return fmt.Errorf("could not query releases: %w", err) + } + defer resp.Body.Close() + + 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:] + } + + 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:] + } + if latestTag == "" || cur == latestTag { + fmt.Println("Already up to date.") + return nil + } + + // Detect Homebrew + if _, err := exec.LookPath("brew"); err == nil { + fmt.Println("Detected Homebrew — attempting to upgrade via brew.") + cmd := exec.Command("brew", "upgrade", "matcha") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + fmt.Println("Successfully upgraded via Homebrew.") + return nil + } + fmt.Printf("Homebrew upgrade failed: %v\n", err) + // fallthrough to other methods + } + + // Detect snap + if _, err := exec.LookPath("snap"); err == nil { + // Check if matcha is installed as a snap + cmdCheck := exec.Command("snap", "list", "matcha") + if err := cmdCheck.Run(); err == nil { + fmt.Println("Detected Snap package — attempting to refresh.") + cmd := exec.Command("snap", "refresh", "matcha") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + fmt.Println("Successfully refreshed snap.") + return nil + } + fmt.Printf("Snap refresh failed: %v\n", err) + // fallthrough + } + } + + // Otherwise attempt to download the proper release asset and replace the binary. + osName := runtime.GOOS + arch := runtime.GOARCH + + // Try to find a matching asset + var assetURL, assetName string + for _, a := range rel.Assets { + n := strings.ToLower(a.Name) + if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) { + assetURL = a.BrowserDownloadURL + assetName = a.Name + break + } + } + if assetURL == "" { + // Try any asset that contains 'matcha' and os/arch as a fallback + for _, a := range rel.Assets { + n := strings.ToLower(a.Name) + if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) { + assetURL = a.BrowserDownloadURL + assetName = a.Name + break + } + } + } + + if assetURL == "" { + return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch) + } + + fmt.Printf("Found release asset: %s\n", assetName) + fmt.Println("Downloading...") + + // Download asset + respAsset, err := http.Get(assetURL) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer respAsset.Body.Close() + + // 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) + + assetPath := filepath.Join(tmpDir, assetName) + outFile, err := os.Create(assetPath) + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + _, err = io.Copy(outFile, respAsset.Body) + outFile.Close() + if err != nil { + return fmt.Errorf("could not write asset to disk: %w", err) + } + + // If it's a tar.gz, extract and find the `matcha` binary + var binPath string + if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") { + f, err := os.Open(assetPath) + if err != nil { + return fmt.Errorf("could not open archive: %w", err) + } + defer f.Close() + gzr, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("could not create gzip reader: %w", err) + } + tr := tar.NewReader(gzr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("error reading tar: %w", err) + } + name := filepath.Base(hdr.Name) + if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) { + // write out the file + binPath = filepath.Join(tmpDir, "matcha") + out, err := os.Create(binPath) + if err != nil { + return fmt.Errorf("could not create binary file: %w", err) + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return fmt.Errorf("could not extract binary: %w", err) + } + out.Close() + if err := os.Chmod(binPath, 0755); err != nil { + return fmt.Errorf("could not make binary executable: %w", err) + } + break + } + } + } else { + // For non-archive assets, assume the asset is the binary itself. + binPath = assetPath + if err := os.Chmod(binPath, 0755); err != nil { + // ignore chmod errors but warn + fmt.Printf("warning: could not chmod downloaded binary: %v\n", err) + } + } + + if binPath == "" { + return fmt.Errorf("could not locate matcha binary inside the release artifact") + } + + // Replace the running executable with the new binary + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("could not determine executable path: %w", err) + } + + // Write the new binary to a temp file in same dir, then rename for atomic replacement. + execDir := filepath.Dir(execPath) + tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix())) + in, err := os.Open(binPath) + if err != nil { + return fmt.Errorf("could not open new binary: %w", err) + } + out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + in.Close() + return fmt.Errorf("could not create temp binary in target dir: %w", err) + } + if _, err := io.Copy(out, in); err != nil { + in.Close() + out.Close() + return fmt.Errorf("could not write new binary to disk: %w", err) + } + in.Close() + out.Close() + + // Attempt to atomically replace + if err := os.Rename(tmpNew, execPath); err != nil { + return fmt.Errorf("could not replace executable: %w", err) + } + + fmt.Println("Successfully updated matcha to", latestTag) + return nil +} + func main() { + // If invoked as CLI update command, run updater and exit. + if len(os.Args) > 1 && os.Args[1] == "update" { + if err := runUpdateCLI(); err != nil { + fmt.Fprintf(os.Stderr, "update failed: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + cfg, err := config.LoadConfig() var initialModel *mainModel if err != nil { diff --git a/tui/choice.go b/tui/choice.go index 47bea29..cae9950 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "reflect" "strings" tea "github.com/charmbracelet/bubbletea" @@ -29,9 +30,12 @@ const choiceLogo = ` ` type Choice struct { - cursor int - choices []string - hasSavedDrafts bool + cursor int + choices []string + hasSavedDrafts bool + UpdateAvailable bool + LatestVersion string + CurrentVersion string } func NewChoice() Choice { @@ -42,8 +46,11 @@ func NewChoice() Choice { } choices = append(choices, "Settings") return Choice{ - choices: choices, - hasSavedDrafts: hasSavedDrafts, + choices: choices, + hasSavedDrafts: hasSavedDrafts, + UpdateAvailable: false, + LatestVersion: "", + CurrentVersion: "", } } @@ -77,6 +84,28 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + + // Handle update notification from other package without importing its type directly. + // We look for a struct named 'UpdateAvailableMsg' that contains 'Latest' and 'Current' string fields. + rv := reflect.ValueOf(msg) + if rv.IsValid() && rv.Kind() == reflect.Struct && rv.Type().Name() == "UpdateAvailableMsg" { + f := rv.FieldByName("Latest") + c := rv.FieldByName("Current") + updated := false + if f.IsValid() && f.Kind() == reflect.String { + m.LatestVersion = f.String() + updated = true + } + if c.IsValid() && c.Kind() == reflect.String { + m.CurrentVersion = c.String() + updated = true + } + if updated { + m.UpdateAvailable = true + return m, nil + } + } + return m, nil } @@ -88,6 +117,18 @@ func (m Choice) View() string { b.WriteString(listHeader.Render("What would you like to do?")) b.WriteString("\n\n") + // If we detected an update, show a short message under the header. + if m.UpdateAvailable { + updateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Padding(0, 1) + cur := m.CurrentVersion + if cur == "" { + cur = "unknown" + } + msg := fmt.Sprintf("Update available: %s (installed: %s) — run `matcha update` to upgrade", m.LatestVersion, cur) + b.WriteString(updateStyle.Render(msg)) + b.WriteString("\n\n") + } + for i, choice := range m.choices { if m.cursor == i { b.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", choice))) From 7c9648958d75590f9269f26d14447247c6bd0679 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 16 Dec 2025 21:41:36 +0400 Subject: [PATCH 2/2] feat: fetcher fetches just from 1 email, instead of the whole inbox --- config/config.go | 9 +++++++++ fetcher/fetcher.go | 24 ++++++++++++++++++++++++ main.go | 8 +++++++- tui/login.go | 11 +++++++++-- tui/messages.go | 3 ++- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/config/config.go b/config/config.go index 500e522..7b266db 100644 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,9 @@ type Account struct { Email string `json:"email"` Password string `json:"password"` ServiceProvider string `json:"service_provider"` // "gmail", "icloud", or "custom" + // FetchEmail is the single email address for which messages should be fetched. + // If empty, it will default to `Email` when accounts are added. + FetchEmail string `json:"fetch_email,omitempty"` // Custom server settings (used when ServiceProvider is "custom") IMAPServer string `json:"imap_server,omitempty"` @@ -144,6 +147,8 @@ func LoadConfig() (*Config, error) { Email: legacyConfig.Email, Password: legacyConfig.Password, ServiceProvider: legacyConfig.ServiceProvider, + // Default FetchEmail to the legacy Email value + FetchEmail: legacyConfig.Email, }, }, } @@ -171,6 +176,10 @@ func (c *Config) AddAccount(account Account) { if account.ID == "" { account.ID = uuid.New().String() } + // Ensure FetchEmail defaults to the login Email if not explicitly set. + if account.FetchEmail == "" && account.Email != "" { + account.FetchEmail = account.Email + } c.Accounts = append(c.Accounts, account) } diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 0ba0790..ed50c26 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -164,9 +164,33 @@ func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) } var toAddrList []string + // Build recipient list from To and Cc for matching and display for _, addr := range msg.Envelope.To { toAddrList = append(toAddrList, addr.Address()) } + for _, addr := range msg.Envelope.Cc { + toAddrList = append(toAddrList, addr.Address()) + } + + // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email + fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail)) + if fetchEmail == "" { + fetchEmail = strings.ToLower(strings.TrimSpace(account.Email)) + } + + // Check if any recipient matches the fetchEmail + matched := false + for _, r := range toAddrList { + if strings.EqualFold(strings.TrimSpace(r), fetchEmail) { + matched = true + break + } + } + + if !matched { + // Skip messages not addressed to the configured fetch email + continue + } emails = append(emails, Email{ UID: msg.Uid, diff --git a/main.go b/main.go index 5fd9bde..4cd9f56 100644 --- a/main.go +++ b/main.go @@ -140,9 +140,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { account := config.Account{ ID: uuid.New().String(), Name: msg.Name, - Email: msg.Email, + Email: msg.Host, // login/email used for authentication comes from Host field in the form Password: msg.Password, ServiceProvider: msg.Provider, + FetchEmail: msg.FetchEmail, } if msg.Provider == "custom" { @@ -152,6 +153,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { account.SMTPPort = msg.SMTPPort } + // Ensure FetchEmail defaults to the login Email (Host) if not explicitly set + if account.FetchEmail == "" && account.Email != "" { + account.FetchEmail = account.Email + } + if m.config == nil { m.config = &config.Config{} } diff --git a/tui/login.go b/tui/login.go index c7e473d..0337423 100644 --- a/tui/login.go +++ b/tui/login.go @@ -23,6 +23,7 @@ const ( inputProvider = iota inputName inputEmail + inputFetchEmail inputPassword inputIMAPServer inputIMAPPort @@ -52,6 +53,9 @@ func NewLogin() *Login { t.Placeholder = "Display Name" t.Prompt = "👤 > " case inputEmail: + t.Placeholder = "Host" + t.Prompt = "🏠 > " + case inputFetchEmail: t.Placeholder = "Email Address" t.Prompt = "✉️ > " case inputPassword: @@ -130,7 +134,8 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return Credentials{ Provider: m.inputs[inputProvider].Value(), Name: m.inputs[inputName].Value(), - Email: m.inputs[inputEmail].Value(), + Host: m.inputs[inputEmail].Value(), + FetchEmail: m.inputs[inputFetchEmail].Value(), Password: m.inputs[inputPassword].Value(), IMAPServer: m.inputs[inputIMAPServer].Value(), IMAPPort: imapPort, @@ -218,6 +223,7 @@ func (m *Login) View() string { m.inputs[inputProvider].View(), m.inputs[inputName].View(), m.inputs[inputEmail].View(), + m.inputs[inputFetchEmail].View(), m.inputs[inputPassword].View(), } @@ -238,12 +244,13 @@ func (m *Login) View() string { } // SetEditMode sets the login form to edit an existing account. -func (m *Login) SetEditMode(accountID, provider, name, email, imapServer string, imapPort int, smtpServer string, smtpPort int) { +func (m *Login) SetEditMode(accountID, provider, name, email, fetchEmail, imapServer string, imapPort int, smtpServer string, smtpPort int) { m.isEditMode = true m.accountID = accountID m.inputs[inputProvider].SetValue(provider) m.inputs[inputName].SetValue(name) m.inputs[inputEmail].SetValue(email) + m.inputs[inputFetchEmail].SetValue(fetchEmail) m.showCustom = provider == "custom" if m.showCustom { diff --git a/tui/messages.go b/tui/messages.go index e2a7cac..acc74fc 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -24,7 +24,8 @@ type SendEmailMsg struct { type Credentials struct { Provider string Name string - Email string + Host string // Host (this was the previous \"Email Address\" field in the UI) + FetchEmail string // Single email address to fetch messages for. If empty, code should default this to Host when creating the account. Password string IMAPServer string IMAPPort int