aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/emlview.go10
-rw-r--r--lib/messageview.go12
-rw-r--r--lib/parse/daterange.go471
-rw-r--r--lib/parse/daterange_test.go97
-rw-r--r--lib/rfc822/message.go383
-rw-r--r--lib/rfc822/message_test.go84
-rw-r--r--lib/rfc822/testdata/message/invalid/hexa26
-rw-r--r--lib/rfc822/testdata/message/valid/quoted-mime-type45
8 files changed, 1117 insertions, 11 deletions
diff --git a/lib/emlview.go b/lib/emlview.go
index ab3ce87e..e27edcb0 100644
--- a/lib/emlview.go
+++ b/lib/emlview.go
@@ -8,8 +8,8 @@ import (
_ "github.com/emersion/go-message/charset"
"git.sr.ht/~rjarry/aerc/lib/crypto"
+ "git.sr.ht/~rjarry/aerc/lib/rfc822"
"git.sr.ht/~rjarry/aerc/models"
- "git.sr.ht/~rjarry/aerc/worker/lib"
)
// EmlMessage implements the RawMessage interface
@@ -37,7 +37,7 @@ func NewEmlMessageView(full []byte, pgp crypto.Provider,
decryptKeys openpgp.PromptFunction, cb func(MessageView, error),
) {
eml := EmlMessage(full)
- messageInfo, err := lib.MessageInfo(&eml)
+ messageInfo, err := rfc822.MessageInfo(&eml)
if err != nil {
cb(nil, err)
return
@@ -52,7 +52,7 @@ func NewEmlMessageView(full []byte, pgp crypto.Provider,
}
if usePGP(messageInfo.BodyStructure) {
- reader := lib.NewCRLFReader(bytes.NewReader(full))
+ reader := rfc822.NewCRLFReader(bytes.NewReader(full))
md, err := pgp.Decrypt(reader, decryptKeys)
if err != nil {
cb(nil, err)
@@ -65,12 +65,12 @@ func NewEmlMessageView(full []byte, pgp crypto.Provider,
return
}
}
- entity, err := lib.ReadMessage(bytes.NewBuffer(msv.message))
+ entity, err := rfc822.ReadMessage(bytes.NewBuffer(msv.message))
if err != nil {
cb(nil, err)
return
}
- bs, err := lib.ParseEntityStructure(entity)
+ bs, err := rfc822.ParseEntityStructure(entity)
if err != nil {
cb(nil, err)
return
diff --git a/lib/messageview.go b/lib/messageview.go
index f2d30086..6c478c6e 100644
--- a/lib/messageview.go
+++ b/lib/messageview.go
@@ -10,9 +10,9 @@ import (
_ "github.com/emersion/go-message/charset"
"git.sr.ht/~rjarry/aerc/lib/crypto"
+ "git.sr.ht/~rjarry/aerc/lib/rfc822"
"git.sr.ht/~rjarry/aerc/log"
"git.sr.ht/~rjarry/aerc/models"
- "git.sr.ht/~rjarry/aerc/worker/lib"
"git.sr.ht/~rjarry/aerc/worker/types"
)
@@ -94,7 +94,7 @@ func NewMessageStoreView(messageInfo *models.MessageInfo, setSeen bool,
if usePGP(messageInfo.BodyStructure) {
msv.FetchFull(func(fm io.Reader) {
- reader := lib.NewCRLFReader(fm)
+ reader := rfc822.NewCRLFReader(fm)
md, err := pgp.Decrypt(reader, decryptKeys)
if err != nil {
cb(nil, err)
@@ -105,12 +105,12 @@ func NewMessageStoreView(messageInfo *models.MessageInfo, setSeen bool,
cb(nil, err)
return
}
- decrypted, err := lib.ReadMessage(bytes.NewBuffer(msv.message))
+ decrypted, err := rfc822.ReadMessage(bytes.NewBuffer(msv.message))
if err != nil {
cb(nil, err)
return
}
- bs, err := lib.ParseEntityStructure(decrypted)
+ bs, err := rfc822.ParseEntityStructure(decrypted)
if err != nil {
cb(nil, err)
return
@@ -162,11 +162,11 @@ func (msv *MessageStoreView) FetchBodyPart(part []int, cb func(io.Reader)) {
}
buf := bytes.NewBuffer(msv.message)
- msg, err := lib.ReadMessage(buf)
+ msg, err := rfc822.ReadMessage(buf)
if err != nil {
panic(err)
}
- reader, err := lib.FetchEntityPartReader(msg, part)
+ reader, err := rfc822.FetchEntityPartReader(msg, part)
if err != nil {
errMsg := fmt.Errorf("Failed to fetch message part: %w", err)
log.Errorf(errMsg.Error())
diff --git a/lib/parse/daterange.go b/lib/parse/daterange.go
new file mode 100644
index 00000000..bd984ed5
--- /dev/null
+++ b/lib/parse/daterange.go
@@ -0,0 +1,471 @@
+package parse
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "git.sr.ht/~rjarry/aerc/log"
+)
+
+const dateFmt = "2006-01-02"
+
+// ParseDateRange parses a date range into a start and end date. Dates are
+// expected to be in the YYYY-MM-DD format.
+//
+// Start and end dates are connected by the range operator ".." where end date
+// is not included in the date range.
+//
+// ParseDateRange can also parse open-ended ranges, i.e. start.. or ..end are
+// allowed.
+//
+// Relative date terms (such as "1 week 1 day" or "1w 1d") can be used, too.
+func DateRange(s string) (start, end time.Time, err error) {
+ s = cleanInput(s)
+ s = ensureRangeOp(s)
+ i := strings.Index(s, "..")
+ switch {
+ case i < 0:
+ // single date
+ start, err = translate(s)
+ if err != nil {
+ err = fmt.Errorf("failed to parse date: %w", err)
+ return
+ }
+ end = start.AddDate(0, 0, 1)
+
+ case i == 0:
+ // end date only
+ if len(s) < 2 {
+ err = fmt.Errorf("no date found")
+ return
+ }
+ end, err = translate(s[2:])
+ if err != nil {
+ err = fmt.Errorf("failed to parse date: %w", err)
+ return
+ }
+
+ case i > 0:
+ // start date first
+ start, err = translate(s[:i])
+ if err != nil {
+ err = fmt.Errorf("failed to parse date: %w", err)
+ return
+ }
+ if len(s[i:]) <= 2 {
+ return
+ }
+ // and end dates if available
+ end, err = translate(s[(i + 2):])
+ if err != nil {
+ err = fmt.Errorf("failed to parse date: %w", err)
+ return
+ }
+ }
+
+ return
+}
+
+type dictFunc = func(bool) time.Time
+
+// dict is a dictionary to translate words to dates. Map key must be at least 3
+// characters for matching purposes.
+var dict map[string]dictFunc = map[string]dictFunc{
+ "today": func(_ bool) time.Time {
+ return time.Now()
+ },
+ "yesterday": func(_ bool) time.Time {
+ return time.Now().AddDate(0, 0, -1)
+ },
+ "week": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -7
+ }
+ return time.Now().AddDate(0, 0,
+ daydiff(time.Monday)+diff)
+ },
+ "month": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(0, diff, -t.Day()+1)
+ },
+ "year": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff, 0, -t.YearDay()+1)
+ },
+ "monday": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -7
+ }
+ return time.Now().AddDate(0, 0,
+ daydiff(time.Monday)+diff)
+ },
+ "tuesday": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -7
+ }
+ return time.Now().AddDate(0, 0,
+ daydiff(time.Tuesday)+diff)
+ },
+ "wednesday": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -7
+ }
+ return time.Now().AddDate(0, 0,
+ daydiff(time.Wednesday)+diff)
+ },
+ "thursday": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -7
+ }
+ return time.Now().AddDate(0, 0,
+ daydiff(time.Thursday)+diff)
+ },
+ "friday": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -7
+ }
+ return time.Now().AddDate(0, 0,
+ daydiff(time.Friday)+diff)
+ },
+ "saturday": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -7
+ }
+ return time.Now().AddDate(0, 0,
+ daydiff(time.Saturday)+diff)
+ },
+ "sunday": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -7
+ }
+ return time.Now().AddDate(0, 0,
+ daydiff(time.Sunday)+diff)
+ },
+ "january": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.January), -t.Day()+1)
+ },
+ "february": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.February), -t.Day()+1)
+ },
+ "march": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.March), -t.Day()+1)
+ },
+ "april": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.April), -t.Day()+1)
+ },
+ "may": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.May), -t.Day()+1)
+ },
+ "june": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.June), -t.Day()+1)
+ },
+ "july": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.July), -t.Day()+1)
+ },
+ "august": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.August), -t.Day()+1)
+ },
+ "september": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.September), -t.Day()+1)
+ },
+ "october": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.October), -t.Day()+1)
+ },
+ "november": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.November), -t.Day()+1)
+ },
+ "december": func(this bool) time.Time {
+ diff := 0
+ if !this {
+ diff = -1
+ }
+ t := time.Now()
+ return t.AddDate(diff,
+ monthdiff(time.December), -t.Day()+1)
+ },
+}
+
+func daydiff(d time.Weekday) int {
+ daydiff := d - time.Now().Weekday()
+ if daydiff > 0 {
+ return int(daydiff) - 7
+ }
+ return int(daydiff)
+}
+
+func monthdiff(d time.Month) int {
+ monthdiff := d - time.Now().Month()
+ if monthdiff > 0 {
+ return int(monthdiff) - 12
+ }
+ return int(monthdiff)
+}
+
+// translate translates regular time words into date strings
+func translate(s string) (time.Time, error) {
+ if s == "" {
+ return time.Now(), fmt.Errorf("empty string")
+ }
+ log.Tracef("input: %s", s)
+ s0 := s
+
+ // if next characters is integer, then parse a relative date
+ if '0' <= s[0] && s[0] <= '9' && hasUnit(s) {
+ relDate, err := RelativeDate(s)
+ if err != nil {
+ log.Errorf("could not parse relative date from '%s': %v",
+ s0, err)
+ } else {
+ log.Tracef("relative date: translated to %v from %s",
+ relDate, s0)
+ return bod(relDate.Apply(time.Now())), nil
+ }
+ }
+
+ // consult dictionary for terms translation
+ s, this, hasPrefix := handlePrefix(s)
+ for term, dateFn := range dict {
+ if term == "month" && !hasPrefix {
+ continue
+ }
+ if strings.Contains(term, s) {
+ log.Tracef("dictionary: translated to %s from %s",
+ term, s0)
+ return bod(dateFn(this)), nil
+ }
+ }
+
+ // this is a regular date, parse it in the normal format
+ log.Infof("parse: translates %s to regular format", s0)
+ return time.Parse(dateFmt, s)
+}
+
+// bod returns the begin of the day
+func bod(t time.Time) time.Time {
+ y, m, d := t.Date()
+ return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
+}
+
+func handlePrefix(s string) (string, bool, bool) {
+ var hasPrefix bool
+ this := true
+ if strings.HasPrefix(s, "this") {
+ hasPrefix = true
+ s = strings.TrimPrefix(s, "this")
+ }
+ if strings.HasPrefix(s, "last") {
+ hasPrefix = true
+ this = false
+ s = strings.TrimPrefix(s, "last")
+ }
+ return s, this, hasPrefix
+}
+
+func cleanInput(s string) string {
+ s = strings.ToLower(s)
+ s = strings.ReplaceAll(s, " ", "")
+ s = strings.ReplaceAll(s, "_", "")
+ return s
+}
+
+// RelDate is the relative date in the past, e.g. yesterday would be
+// represented as RelDate{0,0,1}.
+type RelDate struct {
+ Year uint
+ Month uint
+ Day uint
+}
+
+func (d RelDate) Apply(t time.Time) time.Time {
+ return t.AddDate(-int(d.Year), -int(d.Month), -int(d.Day))
+}
+
+// ParseRelativeDate parses a string of relative terms into a DateAdd.
+//
+// Syntax: N (year|month|week|day) ..
+//
+// The following are valid inputs:
+// 5weeks1day
+// 5w1d
+//
+// Adapted from the Go stdlib in src/time/format.go
+func RelativeDate(s string) (RelDate, error) {
+ s0 := s
+ s = cleanInput(s)
+ var da RelDate
+ for s != "" {
+ var n uint
+
+ var err error
+
+ // expect an integer
+ if !('0' <= s[0] && s[0] <= '9') {
+ return da, fmt.Errorf("not a valid relative term: %s",
+ s0)
+ }
+
+ // consume integer
+ n, s, err = leadingInt(s)
+ if err != nil {
+ return da, fmt.Errorf("cannot read integer in %s",
+ s0)
+ }
+
+ // consume the units
+ i := 0
+ for ; i < len(s); i++ {
+ c := s[i]
+ if '0' <= c && c <= '9' {
+ break
+ }
+ }
+ if i == 0 {
+ return da, fmt.Errorf("missing unit in %s", s0)
+ }
+
+ u := s[:i]
+ s = s[i:]
+ switch u[0] {
+ case 'y':
+ da.Year += n
+ case 'm':
+ da.Month += n
+ case 'w':
+ da.Day += 7 * n
+ case 'd':
+ da.Day += n
+ default:
+ return da, fmt.Errorf("unknown unit %s in %s", u, s0)
+ }
+
+ }
+
+ return da, nil
+}
+
+func hasUnit(s string) (has bool) {
+ for _, u := range "ymwd" {
+ if strings.Contains(s, string(u)) {
+ return true
+ }
+ }
+ return false
+}
+
+// leadingInt parses and returns the leading integer in s.
+//
+// Adapted from the Go stdlib in src/time/format.go
+func leadingInt(s string) (x uint, rem string, err error) {
+ i := 0
+ for ; i < len(s); i++ {
+ c := s[i]
+ if c < '0' || c > '9' {
+ break
+ }
+ x = x*10 + uint(c) - '0'
+ }
+ return x, s[i:], nil
+}
+
+func ensureRangeOp(s string) string {
+ if strings.Contains(s, "..") {
+ return s
+ }
+ s0 := s
+ for _, m := range []string{"this", "last"} {
+ for _, u := range []string{"year", "month", "week"} {
+ term := m + u
+ if strings.Contains(s, term) {
+ if m == "last" {
+ return s0 + "..this" + u
+ } else {
+ return s0 + ".."
+ }
+ }
+ }
+ }
+ return s0
+}
diff --git a/lib/parse/daterange_test.go b/lib/parse/daterange_test.go
new file mode 100644
index 00000000..ff2ae078
--- /dev/null
+++ b/lib/parse/daterange_test.go
@@ -0,0 +1,97 @@
+package parse_test
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "git.sr.ht/~rjarry/aerc/lib/parse"
+)
+
+func TestParseDateRange(t *testing.T) {
+ dateFmt := "2006-01-02"
+ date := func(s string) time.Time { d, _ := time.Parse(dateFmt, s); return d }
+ tests := []struct {
+ s string
+ start time.Time
+ end time.Time
+ }{
+ {
+ s: "2022-11-01",
+ start: date("2022-11-01"),
+ end: date("2022-11-02"),
+ },
+ {
+ s: "2022-11-01..",
+ start: date("2022-11-01"),
+ },
+ {
+ s: "..2022-11-05",
+ end: date("2022-11-05"),
+ },
+ {
+ s: "2022-11-01..2022-11-05",
+ start: date("2022-11-01"),
+ end: date("2022-11-05"),
+ },
+ }
+
+ for _, test := range tests {
+ start, end, err := parse.DateRange(test.s)
+ if err != nil {
+ t.Errorf("ParseDateRange return error for %s: %v",
+ test.s, err)
+ }
+
+ if !start.Equal(test.start) {
+ t.Errorf("wrong start date; expected %v, got %v",
+ test.start, start)
+ }
+
+ if !end.Equal(test.end) {
+ t.Errorf("wrong end date; expected %v, got %v",
+ test.end, end)
+ }
+ }
+}
+
+func TestParseRelativeDate(t *testing.T) {
+ tests := []struct {
+ s string
+ want parse.RelDate
+ }{
+ {
+ s: "5 weeks 1 day",
+ want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
+ },
+ {
+ s: "5_weeks 1_day",
+ want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
+ },
+ {
+ s: "5weeks1day",
+ want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
+ },
+ {
+ s: "5w1d",
+ want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
+ },
+ {
+ s: "5y4m3w1d",
+ want: parse.RelDate{Year: 5, Month: 4, Day: 3*7 + 1},
+ },
+ }
+
+ for _, test := range tests {
+ da, err := parse.RelativeDate(test.s)
+ if err != nil {
+ t.Errorf("ParseRelativeDate return error for %s: %v",
+ test.s, err)
+ }
+
+ if !reflect.DeepEqual(da, test.want) {
+ t.Errorf("results don't match. expected %v, got %v",
+ test.want, da)
+ }
+ }
+}
diff --git a/lib/rfc822/message.go b/lib/rfc822/message.go
new file mode 100644
index 00000000..979d4595
--- /dev/null
+++ b/lib/rfc822/message.go
@@ -0,0 +1,383 @@
+package rfc822
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
+ "time"
+
+ "git.sr.ht/~rjarry/aerc/lib/parse"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "github.com/emersion/go-message"
+ _ "github.com/emersion/go-message/charset"
+ "github.com/emersion/go-message/mail"
+)
+
+// RFC 1123Z regexp
+var dateRe = regexp.MustCompile(`(((Mon|Tue|Wed|Thu|Fri|Sat|Sun))[,]?\s[0-9]{1,2})\s` +
+ `(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s` +
+ `([0-9]{4})\s([0-9]{2}):([0-9]{2})(:([0-9]{2}))?\s([\+|\-][0-9]{4})`)
+
+func FetchEntityPartReader(e *message.Entity, index []int) (io.Reader, error) {
+ if len(index) == 0 {
+ // non multipart, simply return everything
+ return bufReader(e)
+ }
+ if mpr := e.MultipartReader(); mpr != nil {
+ idx := 0
+ for {
+ idx++
+ part, err := mpr.NextPart()
+ if err != nil {
+ return nil, err
+ }
+ if idx == index[0] {
+ rest := index[1:]
+ if len(rest) < 1 {
+ return bufReader(part)
+ }
+ return FetchEntityPartReader(part, index[1:])
+ }
+ }
+ }
+ return nil, fmt.Errorf("FetchEntityPartReader: unexpected code reached")
+}
+
+// TODO: the UI doesn't seem to like readers which aren't buffers
+func bufReader(e *message.Entity) (io.Reader, error) {
+ var buf bytes.Buffer
+ if _, err := io.Copy(&buf, e.Body); err != nil {
+ return nil, err
+ }
+ return &buf, nil
+}
+
+// split a MIME type into its major and minor parts
+func splitMIME(m string) (string, string) {
+ parts := strings.Split(m, "/")
+ if len(parts) != 2 {
+ return parts[0], ""
+ }
+ return parts[0], parts[1]
+}
+
+func fixContentType(h message.Header) (string, map[string]string) {
+ ct, rest := h.Get("Content-Type"), ""
+ if i := strings.Index(ct, ";"); i > 0 {
+ ct, rest = ct[:i], ct[i:]
+ }
+
+ // check if there are quotes around the content type
+ if strings.Contains(ct, "\"") {
+ header := strings.ReplaceAll(ct, "\"", "")
+ if rest != "" {
+ header += rest
+ }
+ h.Set("Content-Type", header)
+ if contenttype, params, err := h.ContentType(); err == nil {
+ return contenttype, params
+ }
+ }
+
+ // if all else fails, return text/plain
+ return "text/plain", nil
+}
+
+func ParseEntityStructure(e *message.Entity) (*models.BodyStructure, error) {
+ var body models.BodyStructure
+ contentType, ctParams, err := e.Header.ContentType()
+ if err != nil {
+ // try to fix the error; if all measures fail, then return a
+ // text/plain content type to display at least plaintext
+ contentType, ctParams = fixContentType(e.Header)
+ }
+
+ mimeType, mimeSubType := splitMIME(contentType)
+ body.MIMEType = mimeType
+ body.MIMESubType = mimeSubType
+ body.Params = ctParams
+ body.Description = e.Header.Get("content-description")
+ body.Encoding = e.Header.Get("content-transfer-encoding")
+ if cd := e.Header.Get("content-disposition"); cd != "" {
+ contentDisposition, cdParams, err := e.Header.ContentDisposition()
+ if err != nil {
+ return nil, fmt.Errorf("could not parse content disposition: %w", err)
+ }
+ body.Disposition = contentDisposition
+ body.DispositionParams = cdParams
+ }
+ body.Parts = []*models.BodyStructure{}
+ if mpr := e.MultipartReader(); mpr != nil {
+ for {
+ part, err := mpr.NextPart()
+ if errors.Is(err, io.EOF) {
+ return &body, nil
+ } else if err != nil {
+ return nil, err
+ }
+ ps, err := ParseEntityStructure(part)
+ if err != nil {
+ return nil, fmt.Errorf("could not parse child entity structure: %w", err)
+ }
+ body.Parts = append(body.Parts, ps)
+ }
+ }
+ return &body, nil
+}
+
+var DateParseError = errors.New("date parsing failed")
+
+func parseEnvelope(h *mail.Header) (*models.Envelope, error) {
+ from, err := parseAddressList(h, "from")
+ if err != nil {
+ return nil, fmt.Errorf("could not read from address: %w", err)
+ }
+ to, err := parseAddressList(h, "to")
+ if err != nil {
+ return nil, fmt.Errorf("could not read to address: %w", err)
+ }
+ cc, err := parseAddressList(h, "cc")
+ if err != nil {
+ return nil, fmt.Errorf("could not read cc address: %w", err)
+ }
+ bcc, err := parseAddressList(h, "bcc")
+ if err != nil {
+ return nil, fmt.Errorf("could not read bcc address: %w", err)
+ }
+ replyTo, err := parseAddressList(h, "reply-to")
+ if err != nil {
+ return nil, fmt.Errorf("could not read reply-to address: %w", err)
+ }
+ subj, err := h.Subject()
+ if err != nil {
+ return nil, fmt.Errorf("could not read subject: %w", err)
+ }
+ msgID, err := h.MessageID()
+ if err != nil {
+ // proper parsing failed, so fall back to whatever is there
+ msgID, err = h.Text("message-id")
+ if err != nil {
+ return nil, err
+ }
+ }
+ var irt string
+ irtList := parse.MsgIDList(h, "in-reply-to")
+ if len(irtList) > 0 {
+ irt = irtList[0]
+ }
+ date, err := parseDate(h)
+ if err != nil {
+ // still return a valid struct plus a sentinel date parsing error
+ // if only the date parsing failed
+ err = fmt.Errorf("%w: %v", DateParseError, err) //nolint:errorlint // can only use %w once
+ }
+ return &models.Envelope{
+ Date: date,
+ Subject: subj,
+ MessageId: msgID,
+ From: from,
+ ReplyTo: replyTo,
+ To: to,
+ Cc: cc,
+ Bcc: bcc,
+ InReplyTo: irt,
+ }, err
+}
+
+// parseDate tries to parse the date from the Date header with non std formats
+// if this fails it tries to parse the received header as well
+func parseDate(h *mail.Header) (time.Time, error) {
+ t, err := h.Date()
+ if err == nil {
+ return t, nil
+ }
+ text, err := h.Text("date")
+ // sometimes, no error occurs but the date is empty.
+ // In this case, guess time from received header field
+ if err != nil || text == "" {
+ t, err := parseReceivedHeader(h)
+ if err == nil {
+ return t, nil
+ }
+ }
+ layouts := []string{
+ // X-Mailer: EarthLink Zoo Mail 1.0
+ "Mon, _2 Jan 2006 15:04:05 -0700 (GMT-07:00)",
+ }
+ for _, layout := range layouts {
+ if t, err := time.Parse(layout, text); err == nil {
+ return t, nil
+ }
+ }
+ // still no success, try the received header as a last resort
+ t, err = parseReceivedHeader(h)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("unrecognized date format: %s", text)
+ }
+ return t, nil
+}
+
+func parseReceivedHeader(h *mail.Header) (time.Time, error) {
+ guess, err := h.Text("received")
+ if err != nil {
+ return time.Time{}, fmt.Errorf("received header not parseable: %w",
+ err)
+ }
+ return time.Parse(time.RFC1123Z, dateRe.FindString(guess))
+}
+
+func parseAddressList(h *mail.Header, key string) ([]*mail.Address, error) {
+ hdr, err := h.Text(key)
+ if err != nil && !message.IsUnknownCharset(err) {
+ return nil, err
+ }
+ if hdr == "" {
+ return nil, nil
+ }
+ add, err := mail.ParseAddressList(hdr)
+ if err != nil {
+ return []*mail.Address{{Name: hdr}}, nil
+ }
+ return add, err
+}
+
+// RawMessage is an interface that describes a raw message
+type RawMessage interface {
+ NewReader() (io.ReadCloser, error)
+ ModelFlags() (models.Flags, error)
+ Labels() ([]string, error)
+ UID() uint32
+}
+
+// MessageInfo populates a models.MessageInfo struct for the message.
+// based on the reader returned by NewReader
+func MessageInfo(raw RawMessage) (*models.MessageInfo, error) {
+ var parseErr error
+ r, err := raw.NewReader()
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+ msg, err := ReadMessage(r)
+ if err != nil {
+ return nil, fmt.Errorf("could not read message: %w", err)
+ }
+ bs, err := ParseEntityStructure(msg)
+ if errors.As(err, new(message.UnknownEncodingError)) {
+ parseErr = err
+ } else if err != nil {
+ return nil, fmt.Errorf("could not get structure: %w", err)
+ }
+ h := &mail.Header{Header: msg.Header}
+ env, err := parseEnvelope(h)
+ if err != nil && !errors.Is(err, DateParseError) {
+ return nil, fmt.Errorf("could not parse envelope: %w", err)
+ // if only the date parsing failed we still get the rest of the
+ // envelop structure in a valid state.
+ // Date parsing errors are fairly common and it's better to be
+ // slightly off than to not be able to read the mails at all
+ // hence we continue here
+ }
+ recDate, _ := parseReceivedHeader(h)
+ if recDate.IsZero() {
+ // better than nothing, if incorrect
+ recDate = env.Date
+ }
+ flags, err := raw.ModelFlags()
+ if err != nil {
+ return nil, err
+ }
+ labels, err := raw.Labels()
+ if err != nil {
+ return nil, err
+ }
+ return &models.MessageInfo{
+ BodyStructure: bs,
+ Envelope: env,
+ Flags: flags,
+ Labels: labels,
+ InternalDate: recDate,
+ RFC822Headers: h,
+ Size: 0,
+ Uid: raw.UID(),
+ Error: parseErr,
+ }, nil
+}
+
+// MessageHeaders populates a models.MessageInfo struct for the message.
+// based on the reader returned by NewReader. Minimal information is included.
+// There is no body structure or RFC822Headers set
+func MessageHeaders(raw RawMessage) (*models.MessageInfo, error) {
+ var parseErr error
+ r, err := raw.NewReader()
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+ msg, err := ReadMessage(r)
+ if err != nil {
+ return nil, fmt.Errorf("could not read message: %w", err)
+ }
+ h := &mail.Header{Header: msg.Header}
+ env, err := parseEnvelope(h)
+ if err != nil && !errors.Is(err, DateParseError) {
+ return nil, fmt.Errorf("could not parse envelope: %w", err)
+ // if only the date parsing failed we still get the rest of the
+ // envelop structure in a valid state.
+ // Date parsing errors are fairly common and it's better to be
+ // slightly off than to not be able to read the mails at all
+ // hence we continue here
+ }
+ recDate, _ := parseReceivedHeader(h)
+ if recDate.IsZero() {
+ // better than nothing, if incorrect
+ recDate = env.Date
+ }
+ flags, err := raw.ModelFlags()
+ if err != nil {
+ return nil, err
+ }
+ labels, err := raw.Labels()
+ if err != nil {
+ return nil, err
+ }
+ return &models.MessageInfo{
+ Envelope: env,
+ Flags: flags,
+ Labels: labels,
+ InternalDate: recDate,
+ Refs: parse.MsgIDList(h, "references"),
+ Size: 0,
+ Uid: raw.UID(),
+ Error: parseErr,
+ }, nil
+}
+
+// NewCRLFReader returns a reader with CRLF line endings
+func NewCRLFReader(r io.Reader) io.Reader {
+ var buf bytes.Buffer
+ scanner := bufio.NewScanner(r)
+ for scanner.Scan() {
+ buf.WriteString(scanner.Text() + "\r\n")
+ }
+ return &buf
+}
+
+// ReadMessage is a wrapper for the message.Read function to read a message
+// from r. The message's encoding and charset are automatically decoded to
+// UTF-8. If an unknown charset is encountered, the error is logged but a nil
+// error is returned since the entity object can still be read.
+func ReadMessage(r io.Reader) (*message.Entity, error) {
+ entity, err := message.Read(r)
+ if message.IsUnknownCharset(err) {
+ log.Warnf("unknown charset encountered")
+ } else if err != nil {
+ return nil, fmt.Errorf("could not read message: %w", err)
+ }
+ return entity, nil
+}
diff --git a/lib/rfc822/message_test.go b/lib/rfc822/message_test.go
new file mode 100644
index 00000000..8730afe2
--- /dev/null
+++ b/lib/rfc822/message_test.go
@@ -0,0 +1,84 @@
+package rfc822_test
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "git.sr.ht/~rjarry/aerc/lib/rfc822"
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+func TestMessageInfoParser(t *testing.T) {
+ rootDir := "testdata/message/valid"
+ msgFiles, err := os.ReadDir(rootDir)
+ die(err)
+
+ for _, fi := range msgFiles {
+ if fi.IsDir() {
+ continue
+ }
+
+ p := fi.Name()
+ t.Run(p, func(t *testing.T) {
+ m := newMockRawMessageFromPath(filepath.Join(rootDir, p))
+ mi, err := rfc822.MessageInfo(m)
+ if err != nil {
+ t.Fatal("Failed to create MessageInfo with:", err)
+ }
+
+ if perr := mi.Error; perr != nil {
+ t.Fatal("Expected no parsing error, but got:", mi.Error)
+ }
+ })
+ }
+}
+
+func TestMessageInfoHandledError(t *testing.T) {
+ rootDir := "testdata/message/invalid"
+ msgFiles, err := os.ReadDir(rootDir)
+ die(err)
+
+ for _, fi := range msgFiles {
+ if fi.IsDir() {
+ continue
+ }
+
+ p := fi.Name()
+ t.Run(p, func(t *testing.T) {
+ m := newMockRawMessageFromPath(filepath.Join(rootDir, p))
+ mi, err := rfc822.MessageInfo(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if perr := mi.Error; perr == nil {
+ t.Fatal("Expected MessageInfo.Error, got none")
+ }
+ })
+ }
+}
+
+type mockRawMessage struct {
+ path string
+}
+
+func newMockRawMessageFromPath(p string) *mockRawMessage {
+ return &mockRawMessage{
+ path: p,
+ }
+}
+
+func (m *mockRawMessage) NewReader() (io.ReadCloser, error) {
+ return os.Open(m.path)
+}
+func (m *mockRawMessage) ModelFlags() (models.Flags, error) { return 0, nil }
+func (m *mockRawMessage) Labels() ([]string, error) { return nil, nil }
+func (m *mockRawMessage) UID() uint32 { return 0 }
+
+func die(err error) {
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/lib/rfc822/testdata/message/invalid/hexa b/lib/rfc822/testdata/message/invalid/hexa
new file mode 100644
index 00000000..56b352ff
--- /dev/null
+++ b/lib/rfc822/testdata/message/invalid/hexa
@@ -0,0 +1,26 @@
+Subject: Confirmation Needed gUdVJQBhsd
+Content-Type: multipart/mixed; boundary="Nextpart_1Q2YJhd197991794467076Pgfa"
+To: <BORK@example.com>
+From: ""REGISTRAR"" <zdglopi-1Q2YJhd-noReply@example.com>
+
+--Nextpart_1Q2YJhd197991794467076Pgfa
+Content-Type: multipart/parallel; boundary="sg54sd54g54sdg54"
+
+--sg54sd54g54sdg54
+Content-Type: multipart/alternative; boundary="54qgf54q546f46qsf46qsf"
+
+--54qgf54q546f46qsf46qsf
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: Hexa
+
+
+
+--54qgf54q546f46qsf46qsf
+Content-Type: text/html; charset=utf-8
+
+
+<CeNteR><a hRef="https://example.com-ap-southeast-example.com.com/example.com#qs=r-acacaeehdiebadgdhgghcaegckhabababaggacihaccajfbacccgaehhbkacb"><b><h2>Congratulations Netflix Customer!</h2></b></a><br>
+<HeaD>
+<ObJECT>
+
+--Nextpart_1Q2YJhd197991794467076Pgfa--
diff --git a/lib/rfc822/testdata/message/valid/quoted-mime-type b/lib/rfc822/testdata/message/valid/quoted-mime-type
new file mode 100644
index 00000000..d9af28a2
--- /dev/null
+++ b/lib/rfc822/testdata/message/valid/quoted-mime-type
@@ -0,0 +1,45 @@
+Subject: Your ECOLINES tickets
+X-PHP-Originating-Script: 33:functions.inc.php
+From: ECOLINES <ecolines@ecolines.lv>
+Content-Type: multipart/mixed;
+ boundary="PHP-mixed-ba319678ca12656cfb8cd46e736ce09d"
+Message-Id: <E1nvIQS-0004tm-Bc@legacy.ecolines.net>
+Date: Sun, 29 May 2022 15:53:44 +0300
+
+--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d
+Content-Type: multipart/alternative; boundary="PHP-alt-ba319678ca12656cfb8cd46e736ce09d"
+
+--PHP-alt-ba319678ca12656cfb8cd46e736ce09d
+Content-Type: text/plain; charset="UTF-8"
+Content-Transfer-Encoding: 7bit
+
+Your tickets are attached to this message. Also You can print out Your tickets from our website www.ecolines.net<b
+r />
+…
+
+--PHP-alt-ba319678ca12656cfb8cd46e736ce09d
+Content-Type: text/html; charset="UTF-8"
+Content-Transfer-Encoding: 7bit
+
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+…
+
+--PHP-alt-ba319678ca12656cfb8cd46e736ce09d--
+
+--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d
+Content-Type: "application/pdf"; name="17634428.pdf"
+Content-Disposition: attachment; filename="17634428.pdf"
+Content-Transfer-Encoding: base64
+
+JVBERi0xLjQKMSAwIG9iago8PAovVGl0bGUgKP7/AFkAbwB1AHIAIAB0AGkAYwBrAGUAdCkKL0Ny
+…
+
+--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d
+Content-Type: "application/pdf"; name="invoice-6385490.pdf"
+Content-Disposition: attachment; filename="invoice-6385490.pdf"
+Content-Transfer-Encoding: base64
+
+JVBERi0xLjQKMSAwIG9iago8PAovVGl0bGUgKP7/AEkAbgB2AG8AaQBjAGUpCi9DcmVhdG9yICj+
+…
+
+--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d--