Initial commit

This commit is contained in:
Johan Jongsma 2026-02-01 08:03:59 +00:00
commit 1284a4a390
7 changed files with 1082 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
.env
*.log
.DS_Store

18
config.yaml Normal file
View File

@ -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"

14
go.mod Normal file
View File

@ -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
)

42
go.sum Normal file
View File

@ -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=

BIN
mail-bridge Executable file

Binary file not shown.

826
main.go Normal file
View File

@ -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"})
}

177
mime.go Normal file
View File

@ -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")
}