commit fef0719277bade45c74dec85681552b4162e21fb Author: Simon Rieger Date: Mon Jun 23 00:48:54 2025 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..041dde8 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +IMAP_SERVER: +IMAP_PORT: +EMAIL_USER: +EMAIL_PASSWORD: +NTFY_SERVER: +NTFY_TOPIC: +NTFY_USER: +NTFY_PASSWORD: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bda3598 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.24-alpine AS builder +RUN apk add --no-cache git +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o imap-ntfy . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /app +COPY --from=builder /app/imap-ntfy . +CMD ["./imap-ntfy"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e704fd7 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# IMAP-ntfy Notification Service + +Eine Go-Anwendung, die jede Minute ein IMAP-Postfach abfragt und bei neuen E-Mails eine Benachrichtigung per ntfy sendet. Unterstützt variable ntfy-Server, Authentifizierung und einen Action-Button für Antworten. + +## Features + +- **Polling:** Überprüft alle 60 Sekunden das IMAP-Postfach auf neue E-Mails. +- **Benachrichtigung:** Sendet bei neuen E-Mails einen Push an einen ntfy-Server. +- **Authentifizierung:** Unterstützt Basic Auth für ntfy. +- **Action-Button:** Fügt einen "Antworten"-Button mit Reply-To-Adresse hinzu. +- **Konfiguration:** Alle Einstellungen erfolgen über Umgebungsvariablen. +- **Docker:** Einfache Deployment-Option mit Docker und Docker Compose. +- **Text-Inhalt:** Extrahiert den reinen Text-Inhalt der E-Mail und kürzt ihn auf 200 Zeichen. + +## Voraussetzungen + +- **Go** (mind. 1.21, nur für lokale Entwicklung) +- **Docker** und **Docker Compose** +- **IMAP-fähiges E-Mail-Postfach** +- **ntfy-Server** (z.B. `https://ntfy.sh` oder selbst gehostet) + +## Konfiguration + +Die Anwendung wird ausschließlich über Umgebungsvariablen konfiguriert: + +| Variable | Beschreibung | Beispielwert | +|------------------|--------------------------------------|-----------------------------| +| `IMAP_SERVER` | IMAP-Server-Hostname | `imap.gmail.com` | +| `IMAP_PORT` | IMAP-Port | `993` | +| `EMAIL_USER` | E-Mail-Adresse | `your@email.com` | +| `EMAIL_PASSWORD` | E-Mail-Passwort oder App-Passwort | `your-secret-password` | +| `NTFY_SERVER` | ntfy-Server-URL | `https://ntfy.sh` | +| `NTFY_TOPIC` | ntfy-Topic | `my-github-notifications` | +| `NTFY_USER` | ntfy-Benutzername (optional) | `ntfy-username` | +| `NTFY_PASSWORD` | ntfy-Passwort (optional) | `ntfy-password` | + +## Installation und Start + +### 1. Repository klonen + +```bash +git clone https://github.com/your-repository/imap-ntfy.git +cd imap-ntfy +``` + +### 2. Umgebungsvariablen anpassen + +Passe die Werte in der Datei `docker-compose.yml` an deine Einstellungen an. + +### 3. Docker Compose starten + +```bash +docker-compose up --build -d +``` + +### 4. Logs überwachen + +```bash +docker-compose logs -f +``` + +## Sicherheitshinweise + +- **Verwende App-Passwörter** für E-Mail-Konten. +- **Sensible Daten** wie Passwörter sollten nicht in Klartext in Versionskontrollsystemen gespeichert werden. +- **Für ntfy:** Setze `NTFY_USER` und `NTFY_PASSWORD` für geschützte Topics. + +## Beispiel für eine ntfy-Benachrichtigung + +**Titel:** +`Testnachricht` + +**Inhalt:** +``` +Betreff: Testnachricht +Von: absender@example.com +Antwort an: absender@example.com + +Hallo, dies ist eine Testnachricht. Hier steht der Inhalt der E-Mail, auf maximal 200 Zeichen gekürzt... +``` + +**Action-Button:** +"Antworten" – öffnet das Standard-E-Mail-Programm mit vorausgefülltem Empfänger. + +## Technische Details + +- **Polling-Intervall:** 60 Sekunden +- **Markierung:** Nach erfolgreicher Benachrichtigung wird die E-Mail als gelesen markiert (`\Seen`) +- **Action-Button:** Fügt einen "Antworten"-Button mit Reply-To-Adresse hinzu +- **Text-Inhalt:** Es wird nur der reine Text-Inhalt der E-Mail extrahiert und verwendet +- **Flexibilität:** Unterstützt jeden ntfy-Server, auch selbst gehostet + +--- + +**Hinweis:** +Die Anwendung ist für den produktiven Einsatz geeignet und kann leicht erweitert werden. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..14480f9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + imap-ntfy: + build: . + environment: + IMAP_SERVER: ${IMAP_SERVER} + IMAP_PORT: ${IMAP_PORT} + EMAIL_USER: ${EMAIL_USER} + EMAIL_PASSWORD: ${EMAIL_PASSWORD} + NTFY_SERVER: ${NTFY_SERVER} + NTFY_TOPIC: ${NTFY_TOPIC} + NTFY_USER: ${NTFY_USER} + NTFY_PASSWORD: ${NTFY_PASSWORD} + restart: unless-stopped +networks: {} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..af3749f --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module imap-ntfy + +go 1.24.1 + +require ( + github.com/emersion/go-imap v1.2.1 + github.com/emersion/go-message v0.15.0 +) + +require ( + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect + golang.org/x/text v0.3.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..583c916 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3c494e0 --- /dev/null +++ b/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-message/mail" +) + +func main() { + log.SetOutput(os.Stdout) + log.Println("IMAP-ntfy Notification Service gestartet") + + imapServer := os.Getenv("IMAP_SERVER") + imapPort := os.Getenv("IMAP_PORT") + emailUser := os.Getenv("EMAIL_USER") + emailPass := os.Getenv("EMAIL_PASSWORD") + ntfyServer := os.Getenv("NTFY_SERVER") + ntfyTopic := os.Getenv("NTFY_TOPIC") + ntfyUser := os.Getenv("NTFY_USER") + ntfyPass := os.Getenv("NTFY_PASSWORD") + + if ntfyServer == "" { + ntfyServer = "https://ntfy.sh" + log.Println("NTFY_SERVER nicht gesetzt, verwende Standardwert:", ntfyServer) + } + + for { + log.Println("Starte IMAP-Abfrage...") + + addr := fmt.Sprintf("%s:%s", imapServer, imapPort) + c, err := client.DialTLS(addr, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + log.Printf("IMAP-Verbindungsfehler: %v", err) + time.Sleep(1 * time.Minute) + continue + } + defer c.Logout() + + if err := c.Login(emailUser, emailPass); err != nil { + log.Printf("IMAP-Login fehlgeschlagen: %v", err) + time.Sleep(1 * time.Minute) + continue + } + + _, err = c.Select("INBOX", false) + if err != nil { + log.Printf("Postfachauswahl fehlgeschlagen: %v", err) + time.Sleep(1 * time.Minute) + continue + } + + criteria := imap.NewSearchCriteria() + criteria.WithoutFlags = []string{"\\Seen"} + ids, err := c.Search(criteria) + if err != nil { + log.Printf("Suche fehlgeschlagen: %v", err) + time.Sleep(1 * time.Minute) + continue + } + + if len(ids) > 0 { + log.Printf("Gefunden: %d neue Nachrichten", len(ids)) + seqset := new(imap.SeqSet) + seqset.AddNum(ids...) + + section := &imap.BodySectionName{} + items := []imap.FetchItem{imap.FetchEnvelope, section.FetchItem()} + + messages := make(chan *imap.Message, 10) + go func() { + if err := c.Fetch(seqset, items, messages); err != nil { + log.Printf("Fetch fehlgeschlagen: %v", err) + } + }() + + for msg := range messages { + subject := msg.Envelope.Subject + from := "" + if len(msg.Envelope.From) > 0 { + from = msg.Envelope.From[0].Address() + } + + replyTo := "" + if len(msg.Envelope.ReplyTo) > 0 { + replyTo = msg.Envelope.ReplyTo[0].Address() + } else if from != "" { + replyTo = from + } + + var bodyText string + if r := msg.GetBody(section); r != nil { + bodyBytes, err := io.ReadAll(r) + if err != nil { + log.Printf("Fehler beim Lesen des Bodys: %v", err) + } else { + bodyText = extractTextFromBody(bodyBytes) + if len(bodyText) > 200 { + bodyText = bodyText[:200] + "..." + } + } + } + + message := fmt.Sprintf( + "Betreff: %s\nVon: %s\nAntwort an: %s\n\n%s", + subject, from, replyTo, bodyText, + ) + + sendNtfyNotification(ntfyServer, ntfyTopic, ntfyUser, ntfyPass, + subject, message, replyTo) + } + + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.SeenFlag} + if err := c.Store(seqset, item, flags, nil); err != nil { + log.Printf("Markieren fehlgeschlagen: %v", err) + } + } else { + log.Println("Keine neuen Nachrichten gefunden.") + } + + c.Logout() + log.Println("Warte 1 Minute bis zur nächsten Abfrage...") + time.Sleep(1 * time.Minute) + } +} + +func extractTextFromBody(bodyBytes []byte) string { + r := strings.NewReader(string(bodyBytes)) + mr, err := mail.CreateReader(r) + if err != nil { + return string(bodyBytes) + } + + var textContent strings.Builder + + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + continue + } + + switch h := p.Header.(type) { + case *mail.InlineHeader: + contentType, _, _ := h.ContentType() + if contentType == "text/plain" { + partBytes, _ := io.ReadAll(p.Body) + textContent.Write(partBytes) + } + } + } + + if textContent.Len() > 0 { + return textContent.String() + } + return string(bodyBytes) +} + +func sendNtfyNotification(server, topic, user, pass, title, message, replyTo string) { + url := fmt.Sprintf("%s/%s", server, topic) + log.Printf("Sende ntfy-Benachrichtigung an %s", url) + + req, _ := http.NewRequest("POST", url, strings.NewReader(message)) + req.Header.Set("Title", title) + req.Header.Set("Content-Type", "text/plain") + + if replyTo != "" { + action := fmt.Sprintf("view, Antworten, mailto:%s", replyTo) + req.Header.Set("Actions", action) + log.Printf("Füge Action-Button hinzu: %s", action) + } + + if user != "" && pass != "" { + req.SetBasicAuth(user, pass) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("ntfy-Fehler: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("ntfy-Fehlerstatus: %d", resp.StatusCode) + log.Printf("Fehlermeldung: %s", string(body)) + } else { + log.Println("ntfy-Benachrichtigung erfolgreich gesendet") + } +}