commit 1284a4a39017ede54455ae8aff0495dce3b79d5b Author: Johan Jongsma Date: Sun Feb 1 08:03:59 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d82971 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +*.log +.DS_Store + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..b092b76 --- /dev/null +++ b/config.yaml @@ -0,0 +1,18 @@ +server: + host: 127.0.0.1 + port: 8025 + +accounts: + proton: + host: 127.0.0.1 + port: 1143 + username: tj@jongsma.me + password: ${PROTON_BRIDGE_PASSWORD} + tls: starttls + watch: + - INBOX + +webhook: + enabled: true + url: http://localhost:18789/hooks/mail + token: "kuma-alert-token-2026" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..214ffe2 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module mail-bridge + +go 1.22.0 + +require ( + github.com/emersion/go-imap/v2 v2.0.0-beta.7 + github.com/emersion/go-message v0.18.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b012eba --- /dev/null +++ b/go.sum @@ -0,0 +1,42 @@ +github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8= +github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mail-bridge b/mail-bridge new file mode 100755 index 0000000..5572835 Binary files /dev/null and b/mail-bridge differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..20ff493 --- /dev/null +++ b/main.go @@ -0,0 +1,826 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/signal" + "sort" + "strings" + "sync" + "syscall" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "gopkg.in/yaml.v3" +) + +// Config structures +type Config struct { + Server ServerConfig `yaml:"server"` + Accounts map[string]AccountConfig `yaml:"accounts"` + Webhook WebhookConfig `yaml:"webhook"` +} + +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type AccountConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + TLS string `yaml:"tls"` // "ssl", "starttls", "none" + Watch []string `yaml:"watch"` +} + +type WebhookConfig struct { + Enabled bool `yaml:"enabled"` + URL string `yaml:"url"` + Token string `yaml:"token"` +} + +// Message represents an email message +type Message struct { + UID uint32 `json:"uid"` + Folder string `json:"folder"` + MessageID string `json:"message_id,omitempty"` + Date time.Time `json:"date"` + From string `json:"from"` + To []string `json:"to,omitempty"` + Subject string `json:"subject"` + BodyPreview string `json:"body_preview,omitempty"` + BodyText string `json:"body_text,omitempty"` + BodyHTML string `json:"body_html,omitempty"` + Flags []string `json:"flags"` + HasAttach bool `json:"has_attachments"` + AttachNames []string `json:"attachment_names,omitempty"` +} + +// WebhookPayload for new mail events +type WebhookPayload struct { + Event string `json:"event"` + Account string `json:"account"` + Folder string `json:"folder"` + Message *Message `json:"message,omitempty"` +} + +// Global state +var ( + config Config + idlesMu sync.Mutex +) + +func main() { + configPath := flag.String("config", "config.yaml", "Path to config file") + flag.Parse() + + // Load config + data, err := os.ReadFile(*configPath) + if err != nil { + log.Fatalf("Failed to read config: %v", err) + } + + // Expand env vars in config + expanded := os.ExpandEnv(string(data)) + if err := yaml.Unmarshal([]byte(expanded), &config); err != nil { + log.Fatalf("Failed to parse config: %v", err) + } + + log.Printf("Loaded %d accounts", len(config.Accounts)) + + // Start IDLE watchers for each account + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + for name, acc := range config.Accounts { + if len(acc.Watch) > 0 { + go startIDLE(ctx, name, acc) + } + } + + // HTTP server + mux := http.NewServeMux() + mux.HandleFunc("/health", handleHealth) + mux.HandleFunc("/accounts", handleListAccounts) + mux.HandleFunc("/accounts/", handleAccountRoutes) + + addr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port) + server := &http.Server{Addr: addr, Handler: logMiddleware(mux)} + + // Graceful shutdown + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + log.Println("Shutting down...") + cancel() + server.Shutdown(context.Background()) + }() + + log.Printf("Starting server on %s", addr) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } +} + +func logMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} + +// IDLE watcher +func startIDLE(ctx context.Context, accountName string, acc AccountConfig) { + for _, folder := range acc.Watch { + go watchFolder(ctx, accountName, acc, folder) + } +} + +func watchFolder(ctx context.Context, accountName string, acc AccountConfig, folder string) { + log.Printf("[%s] Starting IDLE watcher for %s", accountName, folder) + + for { + select { + case <-ctx.Done(): + return + default: + } + + err := runIDLE(ctx, accountName, acc, folder) + if err != nil { + log.Printf("[%s] IDLE error on %s: %v, reconnecting in 10s", accountName, folder, err) + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Second): + } + } + } +} + +func runIDLE(ctx context.Context, accountName string, acc AccountConfig, folder string) error { + client, err := connect(acc) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer client.Close() + + // Select folder and set up unilateral data handler + mbox, err := client.Select(folder, nil).Wait() + if err != nil { + return fmt.Errorf("select %s: %w", folder, err) + } + + prevCount := mbox.NumMessages + log.Printf("[%s] IDLE connected to %s (%d messages)", accountName, folder, prevCount) + + // Run IDLE with periodic refresh + for { + idleCmd, err := client.Idle() + if err != nil { + return fmt.Errorf("idle start: %w", err) + } + + // Use a goroutine to wait for IDLE to complete or get interrupted + idleDone := make(chan error, 1) + go func() { + idleDone <- idleCmd.Wait() + }() + + // Wait for either: context cancel, timeout, or IDLE response + select { + case <-ctx.Done(): + idleCmd.Close() + return nil + case <-time.After(4 * time.Minute): + // Periodic refresh - break IDLE to check for changes + idleCmd.Close() + case err := <-idleDone: + // IDLE ended (server sent something) + if err != nil { + log.Printf("[%s] IDLE ended with error: %v", accountName, err) + } + } + + // Re-select folder to get fresh message count (cached Mailbox() may be stale) + mbox, err = client.Select(folder, nil).Wait() + if err != nil { + log.Printf("[%s] Failed to re-select %s: %v", accountName, folder, err) + return fmt.Errorf("re-select %s: %w", folder, err) + } + + // Check for new messages + if mbox.NumMessages > prevCount { + log.Printf("[%s] New mail in %s: %d -> %d", accountName, folder, prevCount, mbox.NumMessages) + go handleNewMail(accountName, acc, folder) + prevCount = mbox.NumMessages + } else { + prevCount = mbox.NumMessages + } + } +} + +func handleNewMail(accountName string, acc AccountConfig, folder string) { + if !config.Webhook.Enabled { + return + } + + // Get the newest message + client, err := connect(acc) + if err != nil { + log.Printf("[%s] Failed to connect for new mail: %v", accountName, err) + return + } + defer client.Close() + + mbox, err := client.Select(folder, nil).Wait() + if err != nil { + log.Printf("[%s] Failed to select %s: %v", accountName, folder, err) + return + } + + if mbox.NumMessages == 0 { + return + } + + // Fetch the newest message by sequence number + seqSet := imap.SeqSetNum(mbox.NumMessages) + msgs, err := fetchMessages(client, seqSet, folder, true) + if err != nil || len(msgs) == 0 { + log.Printf("[%s] Failed to fetch new message: %v", accountName, err) + return + } + + msg := msgs[0] + log.Printf("[%s] Webhook: from=%q subject=%q", accountName, msg.From, msg.Subject) + + // Send webhook + payload := WebhookPayload{ + Event: "new_mail", + Account: accountName, + Folder: folder, + Message: msg, + } + + sendWebhook(payload) +} + +func sendWebhook(payload WebhookPayload) { + data, err := json.Marshal(payload) + if err != nil { + log.Printf("Webhook marshal error: %v", err) + return + } + + req, err := http.NewRequest("POST", config.Webhook.URL, bytes.NewReader(data)) + if err != nil { + log.Printf("Webhook request error: %v", err) + return + } + + req.Header.Set("Content-Type", "application/json") + if config.Webhook.Token != "" { + req.Header.Set("Authorization", "Bearer "+config.Webhook.Token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("Webhook send error: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + log.Printf("Webhook error %d: %s", resp.StatusCode, string(body)) + } else { + log.Printf("Webhook sent: %s %s", payload.Event, payload.Account) + } +} + +// IMAP connection +func connect(acc AccountConfig) (*imapclient.Client, error) { + addr := fmt.Sprintf("%s:%d", acc.Host, acc.Port) + + var client *imapclient.Client + var err error + + switch acc.TLS { + case "ssl": + client, err = imapclient.DialTLS(addr, nil) + case "starttls": + client, err = imapclient.DialStartTLS(addr, nil) + default: + client, err = imapclient.DialInsecure(addr, nil) + } + if err != nil { + return nil, fmt.Errorf("dial: %w", err) + } + + if err := client.Login(acc.Username, acc.Password).Wait(); err != nil { + client.Close() + return nil, fmt.Errorf("login: %w", err) + } + + return client, nil +} + +// HTTP handlers +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) +} + +func handleListAccounts(w http.ResponseWriter, r *http.Request) { + accounts := make([]string, 0, len(config.Accounts)) + for name := range config.Accounts { + accounts = append(accounts, name) + } + json.NewEncoder(w).Encode(accounts) +} + +func handleAccountRoutes(w http.ResponseWriter, r *http.Request) { + // Parse path: /accounts/{account}/... + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 2 { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + accountName := parts[1] + acc, ok := config.Accounts[accountName] + if !ok { + http.Error(w, "Account not found", http.StatusNotFound) + return + } + + if len(parts) == 2 { + // /accounts/{account} - account info + json.NewEncoder(w).Encode(map[string]interface{}{ + "name": accountName, + "host": acc.Host, + "watch": acc.Watch, + }) + return + } + + switch parts[2] { + case "mailboxes": + handleMailboxes(w, r, accountName, acc) + case "messages": + handleMessages(w, r, accountName, acc, parts[3:]) + default: + http.Error(w, "Not found", http.StatusNotFound) + } +} + +func handleMailboxes(w http.ResponseWriter, r *http.Request, accountName string, acc AccountConfig) { + client, err := connect(acc) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer client.Close() + + switch r.Method { + case "GET": + // List mailboxes + mailboxes, err := client.List("", "*", nil).Collect() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + result := make([]map[string]interface{}, 0, len(mailboxes)) + for _, mbox := range mailboxes { + result = append(result, map[string]interface{}{ + "name": mbox.Mailbox, + "delimiter": string(mbox.Delim), + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) + + case "POST": + // Create mailbox + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + + if err := client.Create(req.Name, nil).Wait(); err != nil { + http.Error(w, fmt.Sprintf("Failed to create mailbox: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "created", "name": req.Name}) + + case "DELETE": + // Delete mailbox + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "name query param is required", http.StatusBadRequest) + return + } + + if err := client.Delete(name).Wait(); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete mailbox: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "name": name}) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleMessages(w http.ResponseWriter, r *http.Request, accountName string, acc AccountConfig, pathParts []string) { + folder := r.URL.Query().Get("folder") + if folder == "" { + folder = "INBOX" + } + + client, err := connect(acc) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer client.Close() + + // Select folder + mbox, err := client.Select(folder, nil).Wait() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to select folder: %v", err), http.StatusInternalServerError) + return + } + + if len(pathParts) == 0 { + // List messages + handleListMessages(w, r, client, mbox, folder) + return + } + + // Single message operations + var uid uint32 + if _, err := fmt.Sscanf(pathParts[0], "%d", &uid); err != nil { + http.Error(w, "Invalid UID", http.StatusBadRequest) + return + } + + switch r.Method { + case "GET": + handleGetMessage(w, r, client, folder, uid) + case "PATCH": + handleUpdateMessage(w, r, client, folder, uid) + case "DELETE": + handleDeleteMessage(w, r, client, folder, uid) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleListMessages(w http.ResponseWriter, r *http.Request, client *imapclient.Client, mbox *imap.SelectData, folder string) { + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + fmt.Sscanf(l, "%d", &limit) + if limit > 200 { + limit = 200 + } + } + + if mbox.NumMessages == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]Message{}) + return + } + + // Use SEARCH to find messages by date + // Search for messages from the last 90 days to get recent ones + since := time.Now().AddDate(0, 0, -90) + searchCriteria := &imap.SearchCriteria{ + Since: since, + } + + searchCmd := client.Search(searchCriteria, nil) + searchData, err := searchCmd.Wait() + if err != nil { + http.Error(w, fmt.Sprintf("Search failed: %v", err), http.StatusInternalServerError) + return + } + + if len(searchData.AllSeqNums()) == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]Message{}) + return + } + + // Convert to SeqSet + seqNums := searchData.AllSeqNums() + var seqSet imap.SeqSet + for _, num := range seqNums { + seqSet.AddNum(num) + } + + msgs, err := fetchMessages(client, seqSet, folder, false) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Sort by date descending (newest first) + sort.Slice(msgs, func(i, j int) bool { + return msgs[i].Date.After(msgs[j].Date) + }) + + // Apply limit after sort + if len(msgs) > limit { + msgs = msgs[:limit] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(msgs) +} + +func fetchMessages(client *imapclient.Client, seqSet imap.SeqSet, folder string, withBody bool) ([]*Message, error) { + options := &imap.FetchOptions{ + Envelope: true, + Flags: true, + UID: true, + BodyStructure: &imap.FetchItemBodyStructure{}, + } + + if withBody { + options.BodySection = []*imap.FetchItemBodySection{{}} + } + + fetchCmd := client.Fetch(seqSet, options) + + var messages []*Message + for { + msgData := fetchCmd.Next() + if msgData == nil { + break + } + + buf, err := msgData.Collect() + if err != nil { + continue + } + + m := &Message{ + UID: uint32(buf.UID), + Folder: folder, + Flags: make([]string, 0), + Date: buf.InternalDate, + } + + if env := buf.Envelope; env != nil { + m.Subject = env.Subject + m.MessageID = env.MessageID + if !env.Date.IsZero() { + m.Date = env.Date + } + + if len(env.From) > 0 { + from := env.From[0] + if from.Name != "" { + m.From = fmt.Sprintf("%s <%s@%s>", from.Name, from.Mailbox, from.Host) + } else { + m.From = fmt.Sprintf("%s@%s", from.Mailbox, from.Host) + } + } + + for _, to := range env.To { + m.To = append(m.To, fmt.Sprintf("%s@%s", to.Mailbox, to.Host)) + } + } + + for _, f := range buf.Flags { + m.Flags = append(m.Flags, string(f)) + } + + // Check for attachments + if buf.BodyStructure != nil { + m.HasAttach, m.AttachNames = checkAttachments(buf.BodyStructure) + } + + // Get body if requested + if withBody && len(buf.BodySection) > 0 { + raw := buf.BodySection[0].Bytes + parsed := ParseMIMEBody(raw) + m.BodyText = parsed.Text + m.BodyHTML = parsed.HTML + m.BodyPreview = truncate(m.BodyText, 500) + } + + messages = append(messages, m) + } + + if err := fetchCmd.Close(); err != nil { + return nil, err + } + + return messages, nil +} + +func checkAttachments(bs imap.BodyStructure) (bool, []string) { + var names []string + + switch s := bs.(type) { + case *imap.BodyStructureSinglePart: + disp := s.Disposition() + if disp != nil && strings.EqualFold(disp.Value, "attachment") { + name := disp.Params["filename"] + if name == "" { + name = disp.Params["name"] + } + if name == "" { + name = s.Filename() + } + if name != "" { + names = append(names, name) + } + return true, names + } + case *imap.BodyStructureMultiPart: + hasAttach := false + for _, child := range s.Children { + if childHas, childNames := checkAttachments(child); childHas { + hasAttach = true + names = append(names, childNames...) + } + } + return hasAttach, names + } + + return false, nil +} + +func handleGetMessage(w http.ResponseWriter, r *http.Request, client *imapclient.Client, folder string, uid uint32) { + uidSet := imap.UIDSetNum(imap.UID(uid)) + + options := &imap.FetchOptions{ + Envelope: true, + Flags: true, + UID: true, + BodyStructure: &imap.FetchItemBodyStructure{}, + BodySection: []*imap.FetchItemBodySection{{}}, + } + + fetchCmd := client.Fetch(uidSet, options) + + msgData := fetchCmd.Next() + if msgData == nil { + fetchCmd.Close() + http.Error(w, "Message not found", http.StatusNotFound) + return + } + + buf, err := msgData.Collect() + if err != nil { + fetchCmd.Close() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + m := &Message{ + UID: uint32(buf.UID), + Folder: folder, + Flags: make([]string, 0), + Date: buf.InternalDate, + } + + if env := buf.Envelope; env != nil { + m.Subject = env.Subject + m.MessageID = env.MessageID + if !env.Date.IsZero() { + m.Date = env.Date + } + + if len(env.From) > 0 { + from := env.From[0] + if from.Name != "" { + m.From = fmt.Sprintf("%s <%s@%s>", from.Name, from.Mailbox, from.Host) + } else { + m.From = fmt.Sprintf("%s@%s", from.Mailbox, from.Host) + } + } + } + + for _, f := range buf.Flags { + m.Flags = append(m.Flags, string(f)) + } + + if len(buf.BodySection) > 0 { + raw := buf.BodySection[0].Bytes + parsed := ParseMIMEBody(raw) + m.BodyText = parsed.Text + m.BodyHTML = parsed.HTML + m.BodyPreview = truncate(m.BodyText, 500) + } + + fetchCmd.Close() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(m) +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +func handleUpdateMessage(w http.ResponseWriter, r *http.Request, client *imapclient.Client, folder string, uid uint32) { + var update struct { + Seen *bool `json:"seen"` + Flagged *bool `json:"flagged"` + MoveTo *string `json:"move_to"` + } + + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + uidSet := imap.UIDSetNum(imap.UID(uid)) + + if update.Seen != nil { + var op imap.StoreFlagsOp + if *update.Seen { + op = imap.StoreFlagsAdd + } else { + op = imap.StoreFlagsDel + } + storeCmd := client.Store(uidSet, &imap.StoreFlags{ + Op: op, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, nil) + storeCmd.Close() + } + + if update.Flagged != nil { + var op imap.StoreFlagsOp + if *update.Flagged { + op = imap.StoreFlagsAdd + } else { + op = imap.StoreFlagsDel + } + storeCmd := client.Store(uidSet, &imap.StoreFlags{ + Op: op, + Silent: true, + Flags: []imap.Flag{imap.FlagFlagged}, + }, nil) + storeCmd.Close() + } + + if update.MoveTo != nil { + client.Move(uidSet, *update.MoveTo).Wait() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "updated"}) +} + +func handleDeleteMessage(w http.ResponseWriter, r *http.Request, client *imapclient.Client, folder string, uid uint32) { + uidSet := imap.UIDSetNum(imap.UID(uid)) + + // Mark deleted and expunge + storeCmd := client.Store(uidSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagDeleted}, + }, nil) + storeCmd.Close() + + expungeCmd := client.Expunge() + expungeCmd.Close() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) +} diff --git a/mime.go b/mime.go new file mode 100644 index 0000000..440fe89 --- /dev/null +++ b/mime.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "html" + "io" + "mime" + "mime/quotedprintable" + "encoding/base64" + "strings" + + "github.com/emersion/go-message" + _ "github.com/emersion/go-message/charset" +) + +// decodeHTMLEntities converts HTML entities to their text equivalents +func decodeHTMLEntities(s string) string { + return html.UnescapeString(s) +} + +// ParsedBody contains extracted text and HTML from a MIME message +type ParsedBody struct { + Text string + HTML string +} + +// ParseMIMEBody extracts text/plain and text/html parts from raw email bytes +func ParseMIMEBody(raw []byte) ParsedBody { + result := ParsedBody{} + + entity, err := message.Read(bytes.NewReader(raw)) + if err != nil { + // Fallback: return raw as text + result.Text = string(raw) + return result + } + + extractParts(entity, &result) + return result +} + +func extractParts(entity *message.Entity, result *ParsedBody) { + mediaType, params, err := mime.ParseMediaType(entity.Header.Get("Content-Type")) + if err != nil { + mediaType = "text/plain" + } + + if strings.HasPrefix(mediaType, "multipart/") { + // Multipart message - recurse into parts + mr := entity.MultipartReader() + if mr == nil { + return + } + + for { + part, err := mr.NextPart() + if err != nil { + break + } + extractParts(part, result) + } + } else { + // Single part - extract content + body, err := io.ReadAll(entity.Body) + if err != nil { + return + } + + // Decode transfer encoding + encoding := strings.ToLower(entity.Header.Get("Content-Transfer-Encoding")) + decoded := decodeBody(body, encoding) + + // Decode charset if needed + charset := params["charset"] + if charset != "" { + // go-message/charset handles this automatically via import + } + + text := string(decoded) + + switch mediaType { + case "text/plain": + if result.Text == "" { + result.Text = text + } + case "text/html": + if result.HTML == "" { + result.HTML = text + // Also extract text from HTML if we don't have plain text + if result.Text == "" { + result.Text = stripHTML(text) + } + } + } + } +} + +func decodeBody(body []byte, encoding string) []byte { + switch encoding { + case "quoted-printable": + decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(body))) + if err != nil { + return body + } + return decoded + case "base64": + decoded, err := base64.StdEncoding.DecodeString(string(body)) + if err != nil { + // Try with whitespace removed + cleaned := strings.ReplaceAll(string(body), "\r\n", "") + cleaned = strings.ReplaceAll(cleaned, "\n", "") + decoded, err = base64.StdEncoding.DecodeString(cleaned) + if err != nil { + return body + } + } + return decoded + default: + return body + } +} + +// stripHTML removes HTML tags and returns plain text +func stripHTML(html string) string { + var result strings.Builder + inTag := false + inStyle := false + inScript := false + + html = strings.ReplaceAll(html, "
", "\n") + html = strings.ReplaceAll(html, "
", "\n") + html = strings.ReplaceAll(html, "
", "\n") + html = strings.ReplaceAll(html, "

", "\n") + html = strings.ReplaceAll(html, "", "\n") + html = decodeHTMLEntities(html) + + for _, r := range html { + switch { + case r == '<': + inTag = true + case r == '>': + inTag = false + case !inTag && !inStyle && !inScript: + result.WriteRune(r) + } + + // Track style/script blocks (simplified) + if inTag { + lower := strings.ToLower(string(r)) + if strings.Contains(lower, "style") { + inStyle = true + } + if strings.Contains(lower, "/style") { + inStyle = false + } + if strings.Contains(lower, "script") { + inScript = true + } + if strings.Contains(lower, "/script") { + inScript = false + } + } + } + + // Clean up whitespace + text := result.String() + lines := strings.Split(text, "\n") + var cleaned []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + cleaned = append(cleaned, line) + } + } + + return strings.Join(cleaned, "\n") +}