Selbstabholer (Linux-Magazin, März 2024)

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.

Eingang hier, Versand dort

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.

IMAP von Hand

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.

Studieren und Archivieren

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.

Zapp Zarapp!

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!

Listing 1: imap-login.go

    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.

Listing 2: imap-fetch.go

    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 }

ID oder UID?

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.

Am Anfang war nur Text

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.

Wie man sich bettet

Ü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.

Listing 3: store.go

    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 }

Vorsicht, Falle!

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

Listing 4: phimap.go

    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.

Listing 5: build.cmd

    1 $ go mod init phimap
    2 $ go mod tidy
    3 $ go build phimap.go imap-login.go imap-fetch.go store.go

Auf geht's mit Debug

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.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/02/snapshot/

[2]

Michael Schilli, "Ordnung halten": Linux-Magazin 22/10, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2022/10/snapshotsnapshot/<U>

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc