Initial commit
This commit is contained in:
commit
1284a4a390
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
Binary file not shown.
|
|
@ -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"})
|
||||
}
|
||||
|
|
@ -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, "<br>", "\n")
|
||||
html = strings.ReplaceAll(html, "<br/>", "\n")
|
||||
html = strings.ReplaceAll(html, "<br />", "\n")
|
||||
html = strings.ReplaceAll(html, "</p>", "\n")
|
||||
html = strings.ReplaceAll(html, "</div>", "\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")
|
||||
}
|
||||
Loading…
Reference in New Issue