Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions cli/daemon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package cli

import (
"fmt"
"os"
"os/exec"
"time"

"github.com/floatpane/matcha/config"
matchaDaemon "github.com/floatpane/matcha/daemon"
"github.com/floatpane/matcha/daemonclient"
"github.com/floatpane/matcha/daemonrpc"
)

// RunDaemon handles the "matcha daemon" subcommand.
func RunDaemon(args []string) {
if len(args) == 0 {
fmt.Println("Usage: matcha daemon <start|stop|status|run>")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" start Start the daemon in the background")
fmt.Println(" stop Stop the running daemon")
fmt.Println(" status Show daemon status")
fmt.Println(" run Run the daemon in the foreground")
os.Exit(1)
}

switch args[0] {
case "start":
runDaemonStart()
case "stop":
runDaemonStop()
case "status":
runDaemonStatus()
case "run":
runDaemonRun()
default:
fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
os.Exit(1)
}
}

func runDaemonStart() {
pidPath := daemonrpc.PIDPath()
if pid, running := matchaDaemon.IsRunning(pidPath); running {
fmt.Printf("Daemon already running (PID %d)\n", pid)
return
}

exe, err := os.Executable()
if err != nil {
fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
os.Exit(1)
}

cmd := exec.Command(exe, "daemon", "run")
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil

cmd.SysProcAttr = daemonclient.DaemonProcAttr()

if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
os.Exit(1)
}

fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
}

func runDaemonStop() {
pidPath := daemonrpc.PIDPath()
pid, running := matchaDaemon.IsRunning(pidPath)
if !running {
fmt.Println("Daemon is not running")
return
}

process, err := os.FindProcess(pid)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
os.Exit(1)
}

if err := process.Signal(os.Interrupt); err != nil {
fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
os.Exit(1)
}

fmt.Printf("Daemon stopped (PID %d)\n", pid)
}

func runDaemonStatus() {
client, err := daemonclient.Dial()
if err != nil {
pidPath := daemonrpc.PIDPath()
if pid, running := matchaDaemon.IsRunning(pidPath); running {
fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
} else {
fmt.Println("Daemon is not running")
}
return
}
defer client.Close()

status, err := client.Status()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
os.Exit(1)
}

fmt.Printf("Daemon running (PID %d)\n", status.PID)
fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
fmt.Printf("Accounts: %d\n", len(status.Accounts))
for _, acct := range status.Accounts {
fmt.Printf(" - %s\n", acct)
}
}

func runDaemonRun() {
cfg, err := config.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
}

d := matchaDaemon.New(cfg)
if err := d.Run(); err != nil {
fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
os.Exit(1)
}
}

func formatUptime(seconds int64) string {
d := time.Duration(seconds) * time.Second
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
}
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
56 changes: 56 additions & 0 deletions cli/oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cli

import (
"fmt"
"os"
"os/exec"

"github.com/floatpane/matcha/config"
)

// RunOAuth handles the "matcha oauth" subcommand for OAuth2 management.
// Usage:
//
// matcha oauth auth <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
// matcha oauth token <email>
// matcha oauth revoke <email>
func RunOAuth(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Commands:")
fmt.Fprintln(os.Stderr, " auth <email> Authorize an email account via OAuth2 (opens browser)")
fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Flags for auth:")
fmt.Fprintln(os.Stderr, " --provider gmail|outlook OAuth2 provider (auto-detected from email)")
fmt.Fprintln(os.Stderr, " --client-id ID OAuth2 client ID")
fmt.Fprintln(os.Stderr, " --client-secret SECRET OAuth2 client secret")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
fmt.Fprintln(os.Stderr, " Gmail: ~/.config/matcha/oauth_client.json")
fmt.Fprintln(os.Stderr, " Outlook: ~/.config/matcha/oauth_client_outlook.json")
os.Exit(1)
}

script, err := config.OAuthScriptPath()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

cmdArgs := append([]string{script}, args...)
cmd := exec.Command("python3", cmdArgs...)
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 {
os.Exit(exitErr.ExitCode())
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
190 changes: 190 additions & 0 deletions cli/send.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package cli

import (
"encoding/base64"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/floatpane/matcha/clib"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
"github.com/floatpane/matcha/sender"
"github.com/google/uuid"
)

// StringSliceFlag implements flag.Value to allow repeated flags.
type StringSliceFlag []string

func (s *StringSliceFlag) String() string { return strings.Join(*s, ", ") }

// Set appends a value to the slice.
func (s *StringSliceFlag) Set(val string) error {
*s = append(*s, val)
return nil
}

// RunSend implements the CLI entrypoint for `matcha send`.
func RunSend(args []string) {
fs := flag.NewFlagSet("send", flag.ExitOnError)

to := fs.String("to", "", "Recipient(s), comma-separated (required)")
cc := fs.String("cc", "", "CC recipient(s), comma-separated")
bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
subject := fs.String("subject", "", "Email subject (required)")
body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
from := fs.String("from", "", "Sender account email (defaults to first configured account)")
withSignature := fs.Bool("signature", true, "Append default signature")
signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")

var attachments StringSliceFlag
fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")

fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Flags:")
fs.PrintDefaults()
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
fmt.Fprintln(os.Stderr, ` echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
}

if err := fs.Parse(args); err != nil {
os.Exit(1)
}

if *to == "" || *subject == "" {
fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
fs.Usage()
os.Exit(1)
}

// Read body from stdin if "-"
emailBody := *body
if emailBody == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
os.Exit(1)
}
emailBody = string(data)
}

// Load config
cfg, err := config.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
if !cfg.HasAccounts() {
fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
os.Exit(1)
}

// Resolve account
var account *config.Account
if *from != "" {
account = cfg.GetAccountByEmail(*from)
if account == nil {
for i := range cfg.Accounts {
if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
account = &cfg.Accounts[i]
break
}
}
}
if account == nil {
fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
os.Exit(1)
}
} else {
account = cfg.GetFirstAccount()
}

// Use account S/MIME/PGP defaults unless explicitly set
if !isFlagSet(fs, "sign-smime") {
*signSMIME = account.SMIMESignByDefault
}
if !isFlagSet(fs, "sign-pgp") {
*signPGP = account.PGPSignByDefault
}

// Append signature
if *withSignature {
if sig, err := config.LoadSignature(); err == nil && sig != "" {
emailBody = emailBody + "\n\n" + sig
}
}

// Process inline images
images := make(map[string][]byte)
re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
matches := re.FindAllStringSubmatch(emailBody, -1)
for _, match := range matches {
imgPath := match[1]
imgData, err := os.ReadFile(imgPath)
if err != nil {
log.Printf("Could not read image file %s: %v", imgPath, err)
continue
}
cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
}

htmlBody := clib.MarkdownToHTML([]byte(emailBody))

// Process attachments
attachMap := make(map[string][]byte)
for _, attachPath := range attachments {
fileData, err := os.ReadFile(attachPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
os.Exit(1)
}
attachMap[filepath.Base(attachPath)] = fileData
}

// Send
recipients := clib.SplitEmails(*to)
ccList := clib.SplitEmails(*cc)
bccList := clib.SplitEmails(*bcc)

rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
if sendErr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
os.Exit(1)
}

// Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
if account.ServiceProvider != "gmail" {
if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
log.Printf("Failed to append sent message to Sent folder: %v", err)
}
}

fmt.Println("Email sent successfully.")
}

// isFlagSet returns true if the named flag was explicitly provided on the command line.
func isFlagSet(fs *flag.FlagSet, name string) bool {
found := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}
Loading
Loading