Um Fotos aus eingehenden E-Mails zu archivieren, schreibt Mike Schilli einen Client in Go und dringt dabei in die Tiefen des IMAP-Protokolls vor.
Typische E-Mail-Clients wie Thunderbird oder Microsoft Outlook machen es recht einfach, eingehende Mails automatisch zu filtern und weiterzuleiten. Neulich kam mir die Idee, frisch geschossene Fotos per E-Mail an meinen Account zu schicken, von wo der Heimcomputer sie automatisch in regelmäßigen Abständen abholt, die Fotos aus dem Mail-Text extrahiert und archiviert (Abbildung 1). Wie schwer wäre es wohl, E-Mails mit einem selbstgestrickten Go-Programm vom Provider abzuholen und die im MIME-Format eingebetteten Fotos herauszupflücken, um sie auf der Platte abzulegen? Gesagt, getan!
Eingang hier, Versand dort
Damit schlüsselfertige E-Mail-Clients Zugriff auf den Mailserver des verwendeten Providers erhalten, benötigen sie drei Parameter: den IMAP-Server samt Port, den Benutzernamen und das Passwort. Daneben fragen die Settings stets nach dem SMTP-Server und dessen Port sowie eventuell weiteren Credentials dort. Der Grund dafür: Für das Abholen und Senden von E-Mails kommen zwei völlig unterschiedliche Technologien zum Einsatz, die meist auf unterschiedlichen Servern laufen.
Ob auf dem Mailserver Post für einen User vorliegt, prüfen Protokolle wie POP3 oder – heutzutage die Regel – IMAP. Dazu kontaktiert der E-Mail-Client den Server und fragt danach, wie viele Nachrichten vorliegen. Er kann sie dann entweder einzeln herunterladen und in Ordnern ablegen oder auch löschen.
Als Provider nutze ich privat Fastmail, eine meiner Meinung nach solide Firma im hemdsärmeligen Handwerk des E-Mail-Business. Ein Account auf Fastmail.com, dessen eingehende Post man im Browser mit einem flinken Web-Interface (Abbildung 2) abfragen kann, kostet 30 US-Dollar im Jahr. Für einen Zugriff mittels IMAP muss der Hobbyist 60 US-Dollar jährlich berappen.
IMAP von Hand
Es ist nicht schwer, die notwendigen IMAP-Befehle zum Abholen einer E-Mail in einer interaktiven Session mit dem Server von Hand einzugeben. Da die Kommunikation mit dem IMAP-Server heute fast immer verschlüsselt über SSL abläuft, dient in Abbildung 3 nicht Nc oder gar das olle Telnet als Terminalprogramm, sondern Openssl mit dem Kommando »s_client«. Es nimmt die Eingaben des Anwenders zeilenweise entgegen, verschlüsselt sie und schickt sie an den Server. Dessen Antworten entschlüsselt der Client wieder und zeigt sie ebenfalls zeilenweise an – quasi Telnet mit Verschlüsselung.
Der Nutzer meldet sich zunächst mit dem Befehl »LOGIN« und Username sowie Passwort an. Mit diesen Zugangsdaten gewährt der Provider nur autorisierten (und zahlenden) Kunden Zugriff auf ihre E-Mails. Um anschließend zu sehen, ob neue Post eingegangen ist, wählt »SELECT INBOX« den gleichnamigen Ordner aus, »SEARCH ALL« fördert die numerischen IDs aller dort liegenden E-Mails zutage. Zurück kommt im Beispiel aus Abbildung 3 eine lange Liste mit 88 E-Mails, durchnummeriert mit IDs von 1 bis 88. Der Client kann anschließend mit »FETCH« eine oder mehrere dieser E-Mails herunterladen und entweder anzeigen oder archivieren oder sonstigen Schabernack damit treiben.
Damit der Client nach getaner Arbeit die in der Inbox verbleibenden Mails nicht bei jedem erneuten Aufruf wieder untersuchen muss, darf er die Nachrichten mit Flags markieren, üblicherweise mit dem Tag »Seen«. Solche E-Mails zeigt ein Webclient dann oft gar nicht mehr an oder graut sie aus, damit der Benutzer weiß, dass es sich um bereits gelesene Post handelt. Statt mit »SEARCH ALL« kann der Client dann mit »SEARCH UNSEEN« suchen und bekommt so nur frische E-Mails zu sehen. Der Client darf die E-Mails auf dem IMAP-Server zudem löschen, wozu er sie mit dem Flag »Deleted« markiert. Beim Ausloggen am Ende der Client-Session wirft der Server alle entsprechenden Nachrichten in den Mülleimer.
Studieren und archivieren
Unser selbstgestrickter Fotoarchivierer geht folgendermaßen vor: Er sucht im Posteingang nach frischen E-Mails, lädt deren Inhalt herunter, extrahiert daraus eventuell im MIME-Format vorliegende Fotos, dekodiert die Bilddaten und schreibt sie unter dem angegebenen Dateinamen in ein Verzeichnis auf die Platte.
Der Server markiert dabei automatisch die Mails, die der Client mit »FETCH« eingeholt hat, mit dem Flag »Seen« als gelesen. Fragt der Client beim nächsten Kontakt wieder nach allen Nachrichten ohne dieses Flag, entfernt der Server bereits vorher bearbeitete Mails aus der Liste der Ergebnisse. So wird jede E-Mail genau einmal heruntergeladen und bearbeitet. Aber aufgepasst: Wählt man die Inbox mit der Standardoption »ReadOnly: true« aus, darf der Client keine Veränderungen an den Server-Daten vornehmen und kann die geholten E-Mails nicht als gelesen markieren. Richtig und wichtig ist stattdessen »ReadOnly: false« (wie später in Listing 2).
Listing 1 definiert die Zugangsdaten für den IMAP-Server in der Struktur »conn« ab Zeile 6. Der Konstruktor »NewIMAP()« füllt die Felder mit aktuellen Werten, die es vor dem Starten des Programms durch echte Daten zu ersetzen gilt. Produktionsreife Software sollte die Werte auch nicht hartkodieren, sondern in eine Konfigurationsdatei auslagern.
Listing 1
imap-login.go
package main
import (
"github.com/emersion/go-imap/v2/imapclient"
"go.uber.org/zap"
)
type conn struct {
HostPort string
User string
Pass string
Cli *imapclient.Client
Log *zap.SugaredLogger
}
func NewIMAP() *conn {
c := conn{
HostPort: "imap.foo.com:993",
User: "me@foo.com",
Pass: "PASSWORD",
}
c.Log = zap.NewExample().Sugar()
return &c
}
func (c *conn) Close() {
c.Log.Debug("Closing connection")
c.Log.Sync()
c.Cli.Close()
}
func (c *conn) Open() error {
c.Log.Debugw("Connecting", "host", c.HostPort)
cli, err := imapclient.DialTLS(c.HostPort, nil)
c.Cli = cli
if err != nil {
return err
}
c.Log.Debug("Connect OK")
c.Log.Debugw("Login", "user", c.User)
if err := c.Cli.Login(c.User, c.Pass).Wait(); err != nil {
return err
}
c.Log.Debug("Login OK")
return nil
}
Zap!
Zeile 19 im Konstruktor initialisiert die Logging-Library »zap« aus dem Hause Uber (dem Taxidienst). Mit der Konfiguration »NewExample()« schreibt die Library alle Debug-Meldungen zu Testzwecken auf die Standardausgabe. In Produktionsumgebungen wäre »NewProductionConfig()« angebracht: Dann landen nur noch Info- und Fehlermeldungen in der Ausgabe, die sich zudem einfach in Log-Dateien umleiten lassen. Beendet das Hauptprogramm später die Kommunikation mit dem IMAP-Server, ruft es den Destruktor »Close()« ab Zeile 22 auf. Er setzt eine Abschlussmeldung ab, kappt die Verbindung und liefert noch baumelnde Log-Nachrichten ab.
Zap ist ein schnelles und handliches Werkzeug zum Absetzen von Log-Messages. Trotz der in Go üblichen strengen Typisierung verträgt die Sprache dessen “sugared” (gezuckerten) Logger-Aufrufe mit variierenden Parametern, die typischerweise im Format »Debugw(“Nachricht“, “Key“, “Value“, …)« daherkommen. Dabei druckt der Logger erst die Nachricht aus und formatiert dann praktischerweise beliebig viele Key-Value-Paare, die Werte vom Typ Integer oder auch Strings vertragen.
Die verschlüsselte TLS-Verbindung zum IMAP-Server baut »Open()« ab Zeile 27 auf. Klappt das, setzt Zeile 36 einen Befehl zum Login mit den Zugangsdaten ab. Akzeptiert der Server die Credentials, kehrt Listing 1 zum aufrufenden Hauptprogramm zurück.
Weiter geht es in Listing 2 mit »UnreadEmails()« ab Zeile 9. Mit »Select()« dockt Zeile 12 an der Inbox des Benutzers an und bekommt schon einmal mit, wie viele E-Mails dort warten. Allerdings interessieren den Client nur die ungelesenen E-Mails, für die Zeile 22 deshalb ein Suchkriterium definiert. Wie die interaktive Session in Abbildung 3 zeigt, ist der Befehl dazu auf Protokollebene ganz simpel. Allerdings versteigt sich die verwendete Go-Library »go-imap« in teilweise närrisches Design und muss auf das Flag »Seen« prüfen und den Test anschließend mit »Not« negieren – unnötig, aber nicht zu ändern.
Listing 2
imap-fetch.go
package main
import (
"github.com/DusanKasan/parsemail"
"github.com/emersion/go-imap/v2"
"io/ioutil"
"regexp"
"strings"
)
func (c *conn) UnreadEmails() (*imap.SeqSet, error) {
ids := new(imap.SeqSet)
// read/write!
mbox, err := c.Cli.Select("INBOX", &imap.SelectOptions{ReadOnly: false}).Wait()
if err != nil {
return ids, err
}
c.Log.Debug("Select ok")
c.Log.Debugw("Inbox", "messages", mbox.NumMessages)
if mbox.NumMessages == 0 {
c.Log.Debug("No message in mailbox")
return ids, nil
}
searchCriteria := &imap.SearchCriteria{Not: []imap.SearchCriteria{{
Flag: []imap.Flag{imap.FlagSeen},
}}}
data, err := c.Cli.UIDSearch(searchCriteria, nil).Wait()
if err != nil {
return ids, err
}
c.Log.Debugw("Unread", "msgs", data.AllNums())
return &data.All, nil
}
func (c *conn) FetchEmails(ids *imap.SeqSet) ([]string, error) {
msgs := []string{}
if len(*ids) == 0 {
c.Log.Debug("No emails")
return msgs, nil
}
fetchOptions := &imap.FetchOptions{
UID: true,
Envelope: true,
BodySection: []*imap.FetchItemBodySection{{}},
}
c.Log.Debugw("Fetching", "uids", ids.String())
messages, err := c.Cli.UIDFetch(*ids, fetchOptions).Collect()
if err != nil {
c.Log.Error("Fetch failed")
return msgs, err
}
c.Log.Debugw("Fetched ", "msgs", len(messages))
for _, msg := range messages {
rawEmail := ""
for _, buf := range msg.BodySection {
rawEmail += string(buf)
}
msgs = append(msgs, rawEmail)
}
return msgs, nil
}
func (c *conn) ProcessEmail(rawEmail string) error {
email, err := parsemail.Parse(strings.NewReader(rawEmail))
if err != nil {
return err
}
c.Log.Debugw("Fetched email",
"subject", email.Subject,
"size", len(email.HTMLBody),
"attms", len(email.Attachments),
)
for _, a := range email.Attachments {
data, err := ioutil.ReadAll(a.Data)
if err != nil {
return err
}
c.Log.Debugw("Attachment",
"file", a.Filename,
"size", len(data),
"type", a.ContentType)
err = c.toStore(a.Filename, data)
if err != nil {
return err
}
}
for _, e := range email.EmbeddedFiles {
data, err := ioutil.ReadAll(e.Data)
if err != nil {
return err
}
c.Log.Debugw("Embedded",
"size", len(data),
"type", e.ContentType)
namerx := regexp.MustCompile(`name="(.*)"`)
matches := namerx.FindStringSubmatch(e.ContentType)
name := "unknown"
if len(matches) >= 2 {
name = matches[1]
}
err = c.toStore(name, data)
if err != nil {
return err
}
}
return nil
}
ID oder UID?
Die Funktion »UIDSearch()« in Zeile 25 startet den Suchbefehl und liefert als Ergebnis eine Reihe von gefundenen E-Mails in Form von eindeutigen numerischen UIDs. Das IMAP-Protokoll bietet sowohl Funktionen, die E-Mails mit verbindungsabhängigen IDs identifizieren, als auch UIDs, die auch über die unmittelbare Client-Server-Verbindung gültig bleiben. Im vorliegenden Fall funktioniert beides. Es gilt nur darauf zu achten, sowohl bei der Suche als auch beim späteren Einholen der E-Mails in einem Nummernkreis zu verbleiben, entweder mit IDs oder UIDs.
Über das Protokoll hantiert der Client dann oft mit Listen von UIDs. Die Go-Library »go-imap« nutzt für diese Sequenzen den eigens definierten Datentyp »SeqSet«, der die Nummern nicht einzeln als Elemente in einem Array speichert, sondern als eine Reihe von Spannen (zum Beispiel 1 bis 2, 5, 7 bis 8). Die Suchfunktion »UIDSearch()« in Zeile 25 liefert in »data.All« die Treffer in einem solchen »seqSet«-Typ zurück, und die nachfolgende Funktion »UIDFetch()« ab Zeile 44 greift ihn als Eingabe auf, um die E-Mails abzuholen.
Jede der gefundenen E-Mails besteht aus einem oder mehreren Teilen, die die For-Schleife ab Zeile 50 für den vollständigen Nachrichtentext in der String-Variablen »rawEmail« einsammelt. Aus jedem dieser Rohtexte fieselt anschließend die Funktion »ProcessEmail()« (Listing 2 ab Zeile 59) die angehängten Mediadaten heraus.
Am Anfang war nur Text
Als die E-Mail in den 1970er-Jahren erfunden wurde, dachte noch niemand daran, Fotos damit zu verschicken – nur Text ging über die Leitung. Da sich am Transportverfahren bis heute nichts geändert hat, müssen E-Mail-Clients auch heute noch, 50 Jahre später, Mediadaten als Text im MIME-Format kodieren.
Abbildung 2 zeigt eine E-Mail mit einem angehefteten Foto im Webclient des Providers Fastmail. Den heruntergeladenen rohen Text derselben E-Mail finden Sie in Abbildung 4. Der Content-Type-Header zeigt mit »multipart/mixed« an, dass der E-Mail-Body unterschiedliche Arten von Medien enthält. Die Zahlenkolonnen im Attribut »boundary« legen fest, an welchen Zeilengrenzen die Kodierung der jeweiligen Teile beginnt. Die JPEG-Daten des angehängten Fotos finden sich weiter unten in textfreundlicher Base64-Kodierung.
Wie man sich bettet …
Attachments sind keineswegs die einzige Möglichkeit, Fotos in E-Mails einzubinden. Auch als sogenannte Embedded Files können sie den Text verzieren, und dann sieht sie der User später in den Text eingebunden und nicht wie bei Attachments erst am Ende der E-Mail. Solche eingebundenen Fotos kann der Client ebenfalls extrahieren: Die verwendete Library »parsemail« von Github bietet dazu die Funktion »Embeddedfiles()«. Listing 2 nutzt sie in der For-Schleife ab Zeile 83, nachdem eine erste For-Schleife (ab Zeile 69) bereits alle eventuell vorhandenen Attachments abgegrast hat.
Die Go-Library »parsemail« entpackt jede in der E-Mail gefundene Datei elegant hinter den Kulissen, indem sie die zugehörigen Datenbereiche im Text aufspürt und die Base64-Kodierung der Fotos aufrollt. Die anschließend in der Variablen »data« liegenden Rohdaten des Fotos speichern Aufrufe der Funktion »toStore()« in den Zeilen 78 und 97 auf der Festplatte. Bei Anhängen liegt der Name der Fotodatei schon vor. Im Fall von in den Text eingebetteten Dateien steht er im Header »Content-Type« des Datenbereichs, und ein Regex-Match wie der in Zeile 92 fieselt ihn heraus.
Vorsicht, Falle!
Mit den vorgegebenen Zielpfaden für die Fotos muss der Client aufpassen: Die E-Mails könnten aus unsicheren Quellen stammen, und keinesfalls darf der Archivierer den eintrudelnden Pfaden blind vertrauen. Es wäre keine gute Idee, Bereiche des Dateisystems außerhalb vorgegebener Fotopfade zu beschreiben. Deshalb stutzt Listing 3 die Pfadnamen mit »Base()« auf den Dateiteil zurecht und legt die Daten unter diesem Namen im Bilderverzeichnis »photos/« ab.
Listing 3
store.go
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
func (c *conn) toStore(fpath string, data []byte) error {
var err error
home, err := os.UserHomeDir()
if err != nil {
return err
}
photoDir := filepath.Join(home, "photos")
os.Mkdir(photoDir, 0755)
base := filepath.Base(fpath)
npath := filepath.Join(photoDir, base)
var f *os.File
if _, err := os.Stat(npath); errors.Is(err, os.ErrNotExist) {
f, err = os.Create(npath)
} else {
suffix := filepath.Ext(base)
prefix := strings.TrimSuffix(base, suffix)
f, err = os.CreateTemp(photoDir, fmt.Sprintf("%s-*%s", prefix, suffix))
}
if err != nil {
return err
}
c.Log.Debugw("Write", "name", f.Name(), "size", len(data))
_, err = f.Write(data)
if err != nil {
return err
}
err = f.Close()
if err != nil {
return err
}
return nil
}
Sollte sich dort schon eine Datei desselben Namens befinden, zum Beispiel, weil in einer früheren Mail schon eine Datei namens »foo.jpg« angekommen war, nutzt der Code den Algorithmus der Standardfunktion »os.CreateTemp()«. Der sorgt durch eingestreute Zufallszahlen für eindeutige Namen (Abbildung 5). Wer eine ausgefeiltere Archivierung der Fotos in einer Datumshierarchie wünscht, kann auf den Importierer aus einem alten Snapshot zurückgreifen [1].
Das Hauptprogramm aus Listing 4 baut nun alles zusammen. Zuerst kontaktiert es in Zeile 4 den IMAP-Server und loggt sich ein. Dann findet es ungelesene E-Mails, holt deren Inhalt mit »FetchEmails()« (Zeile 13) und wirft sie »ProcessEmail()« vor, um eingebettete Fotos zu extrahieren. Damit das Ganze in Schwung kommt, tippen Sie den üblichen Dreisprung zum Kompilieren von Go-Programmen aus Listing 5 ein. Er erzeugt ein Go-Modul, holt mit Tidy die abhängigen Libraries von Github ab, kompiliert sie und linkt dann alles mit »go build« zu einem einzigen Binary zusammen.
Listing 4
phimap.go
package main
func main() {
c := NewIMAP()
err := c.Open()
if err != nil {
c.Log.Fatalw("conn", err)
}
defer c.Close()
ids, err := c.UnreadEmails()
if err != nil {
c.Log.Fatalw("List", err)
}
emails, err := c.FetchEmails(ids)
if err != nil {
c.Log.Fatalw("Fetch", err)
}
for _, email := range emails {
err := c.ProcessEmail(email)
if err != nil {
c.Log.Fatalw("Parse", err)
}
}
}
Listing 5
build.cmd
$ go mod init phimap $ go mod tidy $ go build phimap.go imap-login.go imap-fetch.go store.go
Weiter mit Debug
Einen Testlauf des fertigen Go-Binarys »phimap« (kurz für Photo IMAP) zeigt Abbildung 6. Der Ablauf lässt sich schön über die Debug-Nachrichten verfolgen, die via Zap-System auf der Konsole eintrudeln. Das ermöglicht, die Ursache von eventuell auftretenden Fehlern schnell einzukreisen. Finden Sie das Programm zu geschwätzig, stellen Sie die Initialisierung des Loggers in Listing 1 mit »NewProductionConfig()« im ordnungsgemäßen Lauf auf eine schmallippigere Variante um. Falls Fehler auftreten, meldet sich das Programm trotzdem zu Wort.
Infos
- Snapshot: Mike Schilli, “Ordnung halten”, LM 10/2022, S. 86, https://www.lm-online.de/47383











