commit c31bebc2e5f0631d2d9fcb4d81e3fd2b69e54ca3 Author: Alexander Weinhold Date: Sat Aug 5 18:42:14 2017 +0200 initial commit diff --git a/imap/imap.go b/imap/imap.go new file mode 100644 index 0000000..2a2c13b --- /dev/null +++ b/imap/imap.go @@ -0,0 +1,231 @@ +package imap + +import ( + "bufio" + "crypto/tls" + "fmt" + "log" + "net/mail" + "strconv" + "strings" + "time" +) + +const ( + tag = ". " + other = "* " + maxRead = 11000000 + timeout = 3000 // ms + taggedOK = tag + "OK" + otherOK = other + "OK" + search = other + "SEARCH" +) + +type Error string + +func (err Error) Error() string { + return string(err) +} + +type connection struct { + *tls.Conn + user string + passwd string + unreadMails []*mail.Message + err []error +} + +type stateFunc func(*connection) stateFunc + +func expect(response string, tok string) (string, bool) { + lines := strings.Split(response, "\n") + for _, l := range lines { + line := strings.TrimSpace(l) + if strings.HasPrefix(line, tok) { + return strings.TrimSpace(strings.TrimPrefix(line, tok)), true + } + } + return "", false +} + +func parseMail(response string) (*mail.Message, error) { + reader := bufio.NewReader(strings.NewReader(response)) + for { + line, _ := reader.ReadBytes('\n') + if !strings.HasPrefix(string(line), other) { + break + } + } + return mail.ReadMessage(reader) +} + +/*-------------- states ----------*/ + +func connected(c *connection) stateFunc { + _ = c.read("server hello") + if c.err != nil { + return nil + } else { + return login + } +} + +func login(c *connection) stateFunc { + c.write(cmd(fmt.Sprintf("login %s %s", c.user, c.passwd))) + if c.err != nil { + return logout + } + _ = c.read("login response") + if c.err != nil { + return logout + } else { + return selectBox + } +} + +func selectBox(c *connection) stateFunc { + c.write(cmd("select Inbox")) + if c.err != nil { + return logout + } + _ = c.read("select response") + if c.err != nil { + return logout + } else { + return searchUnseen + } +} + +func searchUnseen(c *connection) stateFunc { + c.write(cmd("uid search unseen")) + if c.err != nil { + return logout + } + s := c.read("search response") + if c.err != nil { + return logout + } else { + if r, ok := expect(s, search); ok { + uids := UIDsFromResponse(r) + return func(c *connection) stateFunc { return fetch(c, uids) } + } else { + return logout + } + } +} + +func fetch(c *connection, ids []int) stateFunc { + for _, id := range ids { + c.write(cmd(fmt.Sprintf("uid fetch %d body[]", id))) + if c.err == nil { + s := c.read("uid fetch of " + strconv.Itoa(id)) + if c.err == nil { + mail, err := parseMail(s) + if err == nil { + c.unreadMails = append(c.unreadMails, mail) + } else { + c.err = append(c.err, err) + } + } + } + } + return logout +} + +func logout(c *connection) stateFunc { + c.write(cmd("logout")) + c.read("logout response") + c.Close() + return nil +} + +/*---------- End of states ----------*/ + +func UIDsFromResponse(response string) []int { + ids := make([]int, 0) + if response == "" { + return ids + } + list := strings.Split(response, " ") + for _, id := range list { + val, err := strconv.Atoi(id) + if err != nil { + log.Println("Could not convert uid '" + id + "'") + } else { + ids = append(ids, val) + } + } + return ids +} + +func cmd(s string) string { + return tag + s + "\r\n" +} + +func (c *connection) write(s string) { + c.Conn.SetWriteDeadline(time.Now().Add(timeout * time.Second)) + bytes := []byte(s) + n, err := c.Conn.Write(bytes) + var desc string + if strings.Contains(s, "login") { + // would not want to print login credentials in case of error + desc = "login" + } else { + desc = s + } + if n != len(bytes) || err != nil { + c.err = append(c.err, Error("Could not write "+desc)) + } +} + +func (c *connection) read(desc string) string { + buf := make([]byte, 0) + reader := bufio.NewReader(c.Conn) + var err error + var conclusion string + if desc == "server hello" { + conclusion = other + } else { + conclusion = tag + } + for { + var line []byte + c.SetReadDeadline(time.Now().Add(timeout * time.Millisecond)) + line, tmpErr := reader.ReadBytes('\n') + if tmpErr == nil && len(buf)+len(line) <= maxRead { + if l := string(line); strings.HasPrefix(l, conclusion) { + if strings.HasPrefix(l, conclusion+"OK") { + break + } else { + buf = nil + err = Error("Did not receive OK in " + desc + ", got: " + l) + break + } + } + buf = append(buf, line...) + } else { + errMsg := "Error while reading " + desc + if tmpErr != nil { + errMsg += "\n" + tmpErr.Error() + } + err = Error(errMsg) + break + } + } + s := string(buf) + if err != nil { + c.err = append(c.err, err) + return "" + } else { + return s + } +} + +func Fetch(c *tls.Conn, user string, passwd string) ([]*mail.Message, []error) { + state := connected + conn := &connection{Conn: c, user: user, passwd: passwd} + for state != nil { + state = state(conn) + } + return conn.unreadMails, conn.err +} diff --git a/libUnreadMail.go b/libUnreadMail.go new file mode 100644 index 0000000..6bbe2af --- /dev/null +++ b/libUnreadMail.go @@ -0,0 +1,163 @@ +package unreadMail + +import ( + "crypto/tls" + "crypto/x509" + "encoding/base64" + "fmt" + "git.gutmet.org/libUnreadMail/imap" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strconv" + "strings" +) + +type Parameters struct { + Server string + Port int + Cert string + User string + Passwd string +} + +type Error string + +func (err Error) Error() string { + return string(err) +} + +func validateParameters(p *Parameters) []error { + var err []error + p.Cert = strings.TrimSpace(p.Cert) + p.User = strings.TrimSpace(p.User) + p.Passwd = strings.TrimSpace(p.Passwd) + + if p.Server == "" { + err = append(err, Error("Empty server address")) + } + if p.Port <= 0 || p.Port > 65535 { + err = append(err, Error("Invalid port number: "+strconv.Itoa(p.Port))) + } + if p.Cert == "" { + err = append(err, Error("Empty server cert")) + } + if p.User == "" { + err = append(err, Error("Empty user")) + } + if p.Passwd == "" { + err = append(err, Error("Empty passwd")) + } + return err +} + +func tlsConnection(p *Parameters) (*tls.Conn, error) { + roots := x509.NewCertPool() + ok := roots.AppendCertsFromPEM([]byte(p.Cert)) + if !ok { + return nil, Error("Failed to set up root cert") + } + conf := &tls.Config{RootCAs: roots} + c, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", p.Server, p.Port), conf) + if err != nil { + return nil, Error("Failed to dial") + } + return c, nil +} + +func normalize(m []byte, encoding string) ([]byte, error) { + var s []byte + var err error + if encoding == "quoted-printable" { + s, err = ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(m)))) + } else if encoding == "base64" { + s, err = ioutil.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(string(m)))) + } + if err == io.ErrUnexpectedEOF { + err = nil + } + return s, err +} + +func getPlainParts(mail *mail.Message) ([]string, []error) { + parts := make([]string, 0) + errs := make([]error, 0) + mediaType, params, err := mime.ParseMediaType(mail.Header.Get("Content-Type")) + if err == nil && strings.HasPrefix(mediaType, "multipart/") { + mr := multipart.NewReader(mail.Body, params["boundary"]) + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + errs = append(errs, err) + } + if contentType := p.Header.Get("Content-Type"); strings.HasPrefix(contentType, "text/plain") { + body, _ := ioutil.ReadAll(p) + encoding := p.Header.Get("Content-Transfer-Encoding") + if encoding != "" { + body, err = normalize(body, encoding) + if err != nil { + body = nil + errs = append(errs, err) + } + } + parts = append(parts, string(body)) + } else { + parts = append(parts, "Non-plain part: "+contentType) + } + } + } else { + // not a multipart message + if strings.HasPrefix(mediaType, "text/plain") { + encoding := mail.Header.Get("Content-Transfer-Encoding") + body, _ := ioutil.ReadAll(mail.Body) + if encoding != "" { + body, err = normalize(body, encoding) + if err != nil { + body = nil + errs = append(errs, err) + } + } + parts = append(parts, string(body)) + } else if contentName := params["name"]; contentName != "" { + parts = append(parts, contentName) + } + } + return parts, errs +} + +func Fetch(p *Parameters) ([]*mail.Message, []error) { + errs := validateParameters(p) + if len(errs) == 0 { + conn, err := tlsConnection(p) + if err != nil { + errs = append(errs, err) + } else { + return imap.Fetch(conn, p.User, p.Passwd) + } + } + return nil, errs +} + +func FetchPlaintext(p *Parameters) ([]string, []error) { + mails, errs := Fetch(p) + mailsPlain := make([]string, 0) + if len(errs) == 0 { + for _, mail := range mails { + // check for mime parts + parts, pErrs := getPlainParts(mail) + errs = append(errs, pErrs...) + date := mail.Header.Get("Date") + from := mail.Header.Get("From") + subject := mail.Header.Get("Subject") + m := fmt.Sprintf("Date: %s\nFrom: %s\nSubject: %s\n\n%s", date, from, subject, strings.Join(parts, "\n\n")) + mailsPlain = append(mailsPlain, m) + } + } + return mailsPlain, errs +} diff --git a/unreadMail/unreadMail.go b/unreadMail/unreadMail.go new file mode 100644 index 0000000..3116dc6 --- /dev/null +++ b/unreadMail/unreadMail.go @@ -0,0 +1,32 @@ +// Example program for the unreadMail library + +package main + +import ( + "flag" + "fmt" + "git.gutmet.org/libUnreadMail" + "os" +) + +func main() { + serverPtr := flag.String("server", "", "server IP") + portPtr := flag.Int("port", 993, "server port") + certPtr := flag.String("cert", "", "PEM cert of server") + userPtr := flag.String("user", "", "user login name") + passwdPtr := flag.String("passwd", "", "password for user") + + flag.Parse() + par := &unreadMail.Parameters{*serverPtr, *portPtr, *certPtr, *userPtr, *passwdPtr} + + mails, errs := unreadMail.FetchPlaintext(par) + if len(errs) > 0 { + for _, err := range errs { + fmt.Fprintln(os.Stderr, err) + } + } + for _, mail := range mails { + fmt.Println(mail) + fmt.Println("\n=*=*=*=*=*=*=*=*=\n") + } +}