Typische Email-Clients wie Thunderbird oder auch Microsoft Outlook machen es recht einfach, eingehende Mails automatisch zu filtern und weiterzuleiten. Allerdings kam mir die Idee, frisch geschossene Fotos per Email an meinen Account zu schicken, von wo der Heimcomputer sie automatisch in regelmäßigen Abständen abholt, die Fotos aus dem Mailtext extrahiert und archiviert. Wie schwer wäre es wohl, Email mit einem selbstgestrickten Go-Programm vom Provider abzuholen, die im MIME-Format eingebetteten Fotos herauszufieseln, um sie auf der Platte abzulegen? Gesagt, getan.
Damit schlüsselfertige Mail-Clients Zugriff auf den Mailserver des verwendeten Providers erhalten, geben User immer zwei Einstellungen vor: Einmal den IMAP-Server mit Port, sowie eine Kombination aus Username und Passwort. Andererseits fragen die Settings auch immer nach dem SMTP-Server/Port, sowie eventuell weiteren Credentials dort. Der Grund dafür ist, dass für das Abholen und Senden von Emails zwei völlig unterschiedliche Technologien zum Einsatz kommen, die meist auf unterschiedlichen Servern laufen.
Abbildung 1: Per Email transportierte Fotos landen im Archiv |
Ob auf dem Mailserver Post für einen User vorliegt, prüfen Protokolle wie IMAP oder POP3, heutzutage meist IMAP. Dazu kontaktiert der Mail-Client den Server, fragt danach, wieviele Emails vorliegen, und kann sie dann entweder selektiv herunterladen, in Ordnern ablegen, oder auch löschen.
Als Provider nutze ich privat Fastmail (Abbildung 2), eine sehr solide Firma im hemdsärmeligen Handwerk des Email-Business, das eine immer wieder aufbrandende und nie endende Schlacht gegen Heerscharen von Spammern führt, die die Email-Funktionen des Internets aus niedrigen Beweggründen ausschlachten. Ein Account auf fastmail.com
, dessen eingehende Post User entweder im Browser mit einem flinken Web-Interface a la Gmail abfragen können (Abbildung 1), kostet 30 Dollar im Jahr, und der Provider hat die Lage gut im Griff. Zugriff mittels IMAP ist etwas teurer, dazu muss der Hobbyist 60 Dollar pro Jahr zu berappen.
Nun ist es auch nicht schwer, die notwendigen IMAP-Befehle zum Abholen der Mail in einer interaktiven Session mit dem Server von Hand einzugeben, wie Abbildung 3 zeigt. Da die Kommunikation mit dem IMAP-Server heute fast immer verschlüsselt über SSL abläuft, nutzt Abbildung 2 als Terminalprogramm nicht nc
oder gar das olle telnet
, sondern openssl
mit dem Kommando s_client
. Das nimmt die Eingaben des Users zeilenweise entgegen, verschlüsselt sie, und schickt sie an den Server. Dessen Antworten entschlüsselt der Client wiederum und zeigt sie ebenfalls zeilenweise an. Wie telnet
mit Verschlüsselung also.
Abbildung 2: Das Browserinterface zeigt die Email mit Foto an. |
Die Session in Abbildung 2 zeigt, dass sich der User zunächst mit dem Befehl LOGIN
mit Username und Passwort anmeldet. Mit diesen Zugangsdaten gewährt der Provider nur autorisierten (und zahlenden) Usern Zugriff auf ihre Emails. Um anschließend zu sehen, ob neue Post eingegangen ist, wählt SELECT INBOX
den gleichnamigen Ordner aus und SEARCH ALL
fördert die numerischen IDs aller in diesem Ordner liegenden Emails zutage.
Abbildung 3: Von Hand gesteuerte IMAP-Session |
Zurück kommt eine lange Liste mit 88 Emails, durchnumeriert mit IDs von 1 bis 88. Der Client kann anschließend mit FETCH eine oder mehrere dieser Emails herunterladen und entweder anzeigen oder archivieren oder Schabernack damit treiben. Damit der Client nach getaner Arbeit die in der Inbox verbleibenden Mails nicht bei jedem erneuten Aufruf wieder untersuchen muss, darf der Client sie mit Flags markieren, üblicherweise mit dem Tag "Seen". Solche Emails zeigt ein Webclient dann oft gar nicht mehr, oder ausgegraut an, damit der User weiß, dass es sich um bereits gelesene Emails handelt. Statt SEARCH ALL
kann der Client dann mit SEARCH UNSEEN
suchen und bekommt damit nur frische Post zu sehen. Auch löschen darf der Client die Emails auf dem IMAP-Server, dazu markiert er sie mit dem Flag "Deleted" und beim Ausloggen am Ende der Client-Session wirft der Server sie tatsächlich in den Mülleimer.
Unser selbstgeschriebener Fotoarchivator geht also folgendermaßen vor: Er sucht nach frischen Emails im Eingang, lädt deren Mailinhalt herunter, extrahiert daraus eventuell im MIME-Format vorliegende Fotos, dekodiert deren 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 das Flag Seen
, fehlen bereits vorher bearbeitete Mails in der Liste der Ergebnisse. So wird jede Email genau einmal heruntergeladen und bearbeitet. Dazu heißt es aber aufpassen: Wer die Inbox mit der Standardoption ReadOnly: true
selektiert, erlaubt dem Client nicht, Modifizierungen an den Serverdaten vorzunehmen, und der Server kann die geholten Emails nicht als gelesen markieren. ReadOnly: false
wie später in Listing 2 ist richtig und wichtig.
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 vor dem Starten des Programms durch echte Daten zu ersetzen sind. Produktionsreife Software sollte die Werte auch nicht hartkodieren, sondern in eine Konfigurationsdatei auslagern.
Weiter initialisiert Zeile 19 im Konstruktor 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, außerdem lassen sie sich einfach in Logdateien umleiten. Beendet das Hauptprogram später die Kommunikation mit dem IMAP-Server, ruft es den Destruktor Close()
ab Zeile 22 auf, der eine Abschlussmeldung absetzt, die Verbindung kappt und noch baumelnde Log-Nachrichten herauslässt.
Zap ist ein sehr schnelles und handliches Werkzeug zum Schicken von Log-Messages. Trotz der in Go üblichen strengen Typisierung vertragen seine sogenannten "Sugared" (gezuckerten) Logger Aufrufe mit variierenden Parametern, typischerweise kommen sie im Format Debugw("nachricht", "key", "value", ...)
daher. Dabei druckt der Logger erst die Nachricht aus, und formatiert dann beliebig viele Key/Value-Paare, die Werte vom Typ Integer oder auch Strings vertragen. Praktisch!
01 package main 02 import ( 03 "github.com/emersion/go-imap/v2/imapclient" 04 "go.uber.org/zap" 05 ) 06 type conn struct { 07 HostPort string 08 User string 09 Pass string 10 Cli *imapclient.Client 11 Log *zap.SugaredLogger 12 } 13 func NewIMAP() *conn { 14 c := conn{ 15 HostPort: "imap.foo.com:993", 16 User: "me@foo.com", 17 Pass: "PASSWORD", 18 } 19 c.Log = zap.NewExample().Sugar() 20 return &c 21 } 22 func (c *conn) Close() { 23 c.Log.Debug("Closing connection") 24 c.Log.Sync() 25 c.Cli.Close() 26 } 27 func (c *conn) Open() error { 28 c.Log.Debugw("Connecting", "host", c.HostPort) 29 cli, err := imapclient.DialTLS(c.HostPort, nil) 30 c.Cli = cli 31 if err != nil { 32 return err 33 } 34 c.Log.Debug("Connect OK") 35 c.Log.Debugw("Login", "user", c.User) 36 if err := c.Cli.Login(c.User, c.Pass).Wait(); err != nil { 37 return err 38 } 39 c.Log.Debug("Login OK") 40 return nil 41 }
Die verschlüsselte TLS-Verbindung zum IMAP-Server baut Open()
ab Zeile 27 auf. Klappt dies, setzt Zeile 36 einen Befehl zum Login mit den Zugangsdaten ab, akzeptiert der Server diese, kehrt Listing 1 zum aufrufenden Hauptprogramm zurück.
Weiter geht's in Listing 2 mit UnreadEmails()
ab Zeile 9. Mit Select()
dockt Zeile 12 an der Inbox des Users an, und bekommt schon einmal mit, wieviele Emails dort warten. Allerdings ist der Client nur an ungelesenen Emails in diesem Verzeichnis interessiert, und so definiert Zeile 22 ein Suchkriterium dafür. Wie die interaktive Session in Abbildung 2 zeigt, ist der Befehl dazu auf Protokollebene ganz simpel einfach, 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.
001 package main 002 import ( 003 "github.com/DusanKasan/parsemail" 004 "github.com/emersion/go-imap/v2" 005 "io/ioutil" 006 "regexp" 007 "strings" 008 ) 009 func (c *conn) UnreadEmails() (*imap.SeqSet, error) { 010 ids := new(imap.SeqSet) 011 // read/write! 012 mbox, err := c.Cli.Select("INBOX", &imap.SelectOptions{ReadOnly: false}).Wait() 013 if err != nil { 014 return ids, err 015 } 016 c.Log.Debug("Select ok") 017 c.Log.Debugw("Inbox", "messages", mbox.NumMessages) 018 if mbox.NumMessages == 0 { 019 c.Log.Debug("No message in mailbox") 020 return ids, nil 021 } 022 searchCriteria := &imap.SearchCriteria{Not: []imap.SearchCriteria{{ 023 Flag: []imap.Flag{imap.FlagSeen}, 024 }}} 025 data, err := c.Cli.UIDSearch(searchCriteria, nil).Wait() 026 if err != nil { 027 return ids, err 028 } 029 c.Log.Debugw("Unread", "msgs", data.AllNums()) 030 return &data.All, nil 031 } 032 func (c *conn) FetchEmails(ids *imap.SeqSet) ([]string, error) { 033 msgs := []string{} 034 if len(*ids) == 0 { 035 c.Log.Debug("No emails") 036 return msgs, nil 037 } 038 fetchOptions := &imap.FetchOptions{ 039 UID: true, 040 Envelope: true, 041 BodySection: []*imap.FetchItemBodySection{{}}, 042 } 043 c.Log.Debugw("Fetching", "uids", ids.String()) 044 messages, err := c.Cli.UIDFetch(*ids, fetchOptions).Collect() 045 if err != nil { 046 c.Log.Error("Fetch failed") 047 return msgs, err 048 } 049 c.Log.Debugw("Fetched ", "msgs", len(messages)) 050 for _, msg := range messages { 051 rawEmail := "" 052 for _, buf := range msg.BodySection { 053 rawEmail += string(buf) 054 } 055 msgs = append(msgs, rawEmail) 056 } 057 return msgs, nil 058 } 059 func (c *conn) ProcessEmail(rawEmail string) error { 060 email, err := parsemail.Parse(strings.NewReader(rawEmail)) 061 if err != nil { 062 return err 063 } 064 c.Log.Debugw("Fetched email", 065 "subject", email.Subject, 066 "size", len(email.HTMLBody), 067 "attms", len(email.Attachments), 068 ) 069 for _, a := range email.Attachments { 070 data, err := ioutil.ReadAll(a.Data) 071 if err != nil { 072 return err 073 } 074 c.Log.Debugw("Attachment", 075 "file", a.Filename, 076 "size", len(data), 077 "type", a.ContentType) 078 err = c.toStore(a.Filename, data) 079 if err != nil { 080 return err 081 } 082 } 083 for _, e := range email.EmbeddedFiles { 084 data, err := ioutil.ReadAll(e.Data) 085 if err != nil { 086 return err 087 } 088 c.Log.Debugw("Embedded", 089 "size", len(data), 090 "type", e.ContentType) 091 namerx := regexp.MustCompile(`name="(.*)"`) 092 matches := namerx.FindStringSubmatch(e.ContentType) 093 name := "unknown" 094 if len(matches) >= 2 { 095 name = matches[1] 096 } 097 err = c.toStore(name, data) 098 if err != nil { 099 return err 100 } 101 } 102 return nil 103 }
Die Funktion UIDSearch()
in Zeile 25 startet den Suchbefehl und liefert als Ergebnis eine Reihe von gefundenen Emails, in Form von eindeutigen numerischen UIDs. Das IMAP-Protokoll bietet sowohl Funktionen, die Emails 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 ist nur darauf zu achten, sowohl bei der Suche als auch beim späteren Einholen der Emails in einem Nummernkreis, entweder mit IDs oder UIDs zu verbleiben.
Über das Protokoll hantiert der Client dann oft mit Listen von UIDs, und 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-2, 5, 7-8). So liefert die Suchfunktion UIDSearch()
in Zeile 25 in data.All
die Treffer in einen solchem seqSet
-Typ zurück, und die nachfolgende Funktion UIDFetch()
ab Zeile 44 greift ihn als Eingabe auf, um ebene jene Emails einzuholen.
Jede dieser gefundenen Emails besteht nun aus einem oder mehreren Teilen, die die for
-Schleife ab Zeile 50 für den vollständigen Mailtext in der Stringvariablen rawEmail
einsammelt. Aus jedem dieser rohen Mailtexte fieselt anschließend die Funktion ProcessEmail()
ab Zeile 59 in Listing 2 die angehängten Mediadaten heraus.
Als Email in den 70er-Jahren des letzten Jahrhunderts erfunden wurde, dachte noch niemand daran, Fotos damit zu verschicken, nur Text ging durch die Leitung. Da sich am Transportverfahren bis heute nichts geändert hat, müssen Mail-Clients auch heute noch, 50 Jahre später, Mediadaten als Text im MIME-Format kodieren. Abbildung 2 zeigt eine Email mit einem angehefteten Foto im Webclient des Providers Fastmail. Den heruntergeladenen rohen Text der Email zeigt hingegen Abbildung 4. Ihr Content-Type-Header zeigt mit multipart/mixed
an, dass der 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.
Abbildung 4: Die Email transportiert das Foto kodiert als Text. |
Übrigens sind Attachments nicht die einzige Möglichkeit, Fotos in Emails einzubinden. Auch als sogenannte "Embedded Files" können sie den Text verzieren, und dann sieht der User später die Fotos in den Text eingebunden und nicht wie bei Attachments nur am Ende der Email. Diese eingebundenen Fotos kann der Client ebenfalls extrahieren, die verwendete Library parsemail
von Github bietet dazu die Funktion Embeddedfiles(), was Listing 2 in der for
-Schleife ab Zeile 83 nutzt, nachdem die erste for
-Schleife (ab Zeile 69) bereits alle eventuell vorhandenen Attachments abgegrast hat.
Die Go-Library parsemail
entpackt jede in der Email 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 der Fotodatei speichern Aufrufe der Funktion toStore()
in den Zeilen 78 und 97 auf der Festplatte ab. Im Falle von Attachments liegt der Name der Fotodatei schon vor, im Falle 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.
01 package main 02 import ( 03 "errors" 04 "fmt" 05 "os" 06 "path/filepath" 07 "strings" 08 ) 09 func (c *conn) toStore(fpath string, data []byte) error { 10 var err error 11 home, err := os.UserHomeDir() 12 if err != nil { 13 return err 14 } 15 photoDir := filepath.Join(home, "photos") 16 os.Mkdir(photoDir, 0755) 17 base := filepath.Base(fpath) 18 npath := filepath.Join(photoDir, base) 19 var f *os.File 20 if _, err := os.Stat(npath); errors.Is(err, os.ErrNotExist) { 21 f, err = os.Create(npath) 22 } else { 23 suffix := filepath.Ext(base) 24 prefix := strings.TrimSuffix(base, suffix) 25 f, err = os.CreateTemp(photoDir, fmt.Sprintf("%s-*%s", prefix, suffix)) 26 } 27 if err != nil { 28 return err 29 } 30 c.Log.Debugw("Write", "name", f.Name(), "size", len(data)) 31 _, err = f.Write(data) 32 if err != nil { 33 return err 34 } 35 err = f.Close() 36 if err != nil { 37 return err 38 } 39 return nil 40 }
Mit den vorgegebenen Zielpfaden für die Fotos muss der Client aufpassen, denn Emails können aus unsicheren Quellen stammen, und keinesfalls darf der Archivierer den eintrudelnden Pfaden blind trauen, schließlich wäre es 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 Fotoverzeichnis photos
ab. Sollte sich dort schon eine Datei gleichen 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 durch eingestreute Zufallszahlen für eindeutige Namen sorgt (Abbildung 5). Wer eine ausgefeiltere Archivierung der Fotos in einer Datumshierarchie wünscht, kann auf den Importierer aus einem alten Snapshot zurückgreifen ([2]).
Abbildung 5: Deduplizierte Dateien nach fünfmaligem Laden der Datei anza.jpg |
01 package main 02 func main() { 03 c := NewIMAP() 04 err := c.Open() 05 if err != nil { 06 c.Log.Fatalw("conn", err) 07 } 08 defer c.Close() 09 ids, err := c.UnreadEmails() 10 if err != nil { 11 c.Log.Fatalw("List", err) 12 } 13 emails, err := c.FetchEmails(ids) 14 if err != nil { 15 c.Log.Fatalw("Fetch", err) 16 } 17 for _, email := range emails { 18 err := c.ProcessEmail(email) 19 if err != nil { 20 c.Log.Fatalw("Parse", err) 21 } 22 } 23 }
Das Hauptprogramm in Listing 4 baut nun alles zusammen, erst kontaktiert es in Zeile 4 den IMAP-Server, loggt sich ein, findet ungelesene Emails, holt deren Inhalt mit FetchEmails()
in Zeile 13 und wirft sie dann ProcessEmail()
vor, um eingebettete Fotos zu extrahieren. Damit das Ganze in Schwung kommt, ist wie üblich der Dreisprung zum Kompilieren von Go-Programmen aus Listing 5 einzutippen. Der 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.
1 $ go mod init phimap 2 $ go mod tidy 3 $ go build phimap.go imap-login.go imap-fetch.go store.go
Einen Testlauf des fertigen Go-Binaries phimap
(kurz für "Photo IMAP") zeigt Abbildung 6 anhand der Debug-Nachrichten, die über das Zap-System auf der Konsole eintrudeln.
Abbildung 6: Testlauf des Fotoarchivierers |
So lässt sich der Ablauf des Programms aus dem Augenwinkel kontrollieren und bei eventuell auftretenden Fehlern schnell die Ursache einkreisen. Wer das Programm weniger geschwätzig wünscht, kann die Initialisierung des Loggers in Listing 19 mit NewProductionConfig()
im ordnungsgemäßen Lauf auf leise stellen. Falls Fehler auftreten, meldet sich das Programm trotzdem zu Wort.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/02/snapshot/
Michael Schilli, "Ordnung halten": Linux-Magazin 22/10, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2022/10/snapshotsnapshot/<U>
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc