From 960e249bd88fa24a28b70b06a6ac8da2683a029e Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Thu, 3 Aug 2023 20:23:57 -0500 Subject: [PATCH] Add support for RFC2369 Exposes parsed URLs from the various list commands described in RFC2369. --- mail/header.go | 97 +++++++++++++++++++ mail/header_test.go | 222 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) diff --git a/mail/header.go b/mail/header.go index df55f52..feff13e 100644 --- a/mail/header.go +++ b/mail/header.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/mail" + "net/url" "os" "strconv" "strings" @@ -211,6 +212,61 @@ func (p *headerParser) parseMsgID() (string, error) { return left + "@" + right, nil } +func (p *headerParser) parseListCommand() (*url.URL, error) { + if !p.skipCFWS() { + return nil, errors.New("mail: malformed parenthetical comment") + } + + // Consume a potential newline + indent. + p.consume('\r') + p.consume('\n') + p.skipSpace() + + if p.consume('N') && p.consume('O') { + if !p.skipCFWS() { + return nil, errors.New("mail: malformed parenthetical comment") + } + + return nil, nil + } + + if !p.consume('<') { + return nil, errors.New("mail: missing '<' in list command") + } + + i := 0 + for p.s[i] != '>' && i+1 < len(p.s) { + i += 1 + } + + var lit string + lit, p.s = p.s[:i], p.s[i:] + + u, err := url.Parse(lit) + if err != nil { + return u, errors.New("mail: malformed URL") + } + + if !p.consume('>') { + return nil, errors.New("mail: missing '>' in list command") + } + + if !p.skipCFWS() { + return nil, errors.New("mail: malformed parenthetical comment") + } + + // If there isn't a comma, we don't care because it means that there aren't + // any other list command URLs. + p.consume(',') + p.skipSpace() + + // Consume a potential newline. + p.consume('\r') + p.consume('\n') + + return u, nil +} + // A Header is a mail header. type Header struct { message.Header @@ -308,6 +364,35 @@ func (h *Header) MsgIDList(key string) ([]string, error) { return l, nil } +// MsgIDList parses a list of URLs from a list command header. It returns URLs. +// If the header field is missing, it returns nil. +// +// This can be used on List-Help, List-Unsubscribe, List-Subscribe, List-Post, +// List-Owner, and List-Archive headers. +// +// See https://www.rfc-editor.org/rfc/rfc2369 for more information. +// +// In the case that the value of List-Post is the special value, "NO", the +// return value is a slice containing one element, nil. +func (h *Header) ListCommandURLList(key string) ([]*url.URL, error) { + v := h.Get(key) + if v == "" { + return nil, nil + } + + p := headerParser{v} + var l []*url.URL + for !p.empty() { + url, err := p.parseListCommand() + if err != nil { + return l, err + } + l = append(l, url) + } + + return l, nil +} + // GenerateMessageID wraps GenerateMessageIDWithHostname and therefore uses the // hostname of the local machine. This is done to not break existing software. // Wherever possible better use GenerateMessageIDWithHostname, because the local @@ -362,6 +447,18 @@ func (h *Header) SetMsgIDList(key string, l []string) { } } +func (h *Header) SetListCommandURLList(key string, urls []*url.URL) { + if len(urls) == 0 { + h.Del(key) + } + + var ids []string + for _, url := range urls { + ids = append(ids, url.String()) + } + h.Set(key, "<"+strings.Join(ids, ">, <")+">") +} + // Copy creates a stand-alone copy of the header. func (h *Header) Copy() Header { return Header{h.Header.Copy()} diff --git a/mail/header_test.go b/mail/header_test.go index 58975c0..974e59c 100644 --- a/mail/header_test.go +++ b/mail/header_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" netmail "net/mail" + "net/url" "reflect" "strings" "testing" @@ -215,3 +216,224 @@ func TestHeader_EmptyAddressList(t *testing.T) { } } + +func TestHeader_ListCommandURLList(t *testing.T) { + tests := []struct { + header string + raw string + urls []*url.URL + xfail bool + }{ + { + header: "List-Help", + raw: " (List Instructions)", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list@host.com", RawQuery: "subject=help"}, + }, + }, + { + header: "List-Help", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list-manager@host.com", RawQuery: "body=info"}, + }, + }, + { + header: "List-Help", + raw: " (Info about the list)", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list-info@host.com"}, + }, + }, + { + header: "List-Help", + raw: ", ", + urls: []*url.URL{ + {Scheme: "http", Host: "www.host.com", Path: "/list/"}, + {Scheme: "mailto", Opaque: "list-info@host.com"}, + }, + }, + { + header: "List-Help", + raw: " (FTP),\r\n\t", + urls: []*url.URL{ + {Scheme: "ftp", Host: "ftp.host.com", Path: "/list.txt"}, + {Scheme: "mailto", Opaque: "list@host.com", RawQuery: "subject=help"}, + }, + }, + { + header: "List-Unsubscribe", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list@host.com", RawQuery: "subject=unsubscribe"}, + }, + }, + { + header: "List-Unsubscribe", + raw: "(Use this command to get off the list)\r\n\t", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list-manager@host.com", RawQuery: "body=unsubscribe%20list"}, + }, + }, + { + header: "List-Unsubscribe", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list-off@host.com"}, + }, + }, + { + header: "List-Unsubscribe", + raw: ",\r\n\t", + urls: []*url.URL{ + {Scheme: "http", Host: "www.host.com", Path: "/list.cgi", RawQuery: "cmd=unsub&lst=list"}, + {Scheme: "mailto", Opaque: "list-request@host.com", RawQuery: "subject=unsubscribe"}, + }, + }, + { + header: "List-Subscribe", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list@host.com", RawQuery: "subject=subscribe"}, + }, + }, + { + header: "List-Subscribe", + raw: "(Use this command to join the list)\r\n\t", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list-manager@host.com", RawQuery: "body=subscribe%20list"}, + }, + }, + { + header: "List-Unsubscribe", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list-on@host.com"}, + }, + }, + { + header: "List-Subscribe", + raw: ",\r\n\t", + urls: []*url.URL{ + {Scheme: "http", Host: "www.host.com", Path: "/list.cgi", RawQuery: "cmd=sub&lst=list"}, + {Scheme: "mailto", Opaque: "list-manager@host.com", RawQuery: "subject=subscribe"}, + }, + }, + { + header: "List-Post", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list@host.com"}, + }, + }, + { + header: "List-Post", + raw: " (Postings are Moderated)", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "moderator@host.com"}, + }, + }, + { + header: "List-Post", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "moderator@host.com", RawQuery: "subject=list%20posting"}, + }, + }, + { + header: "List-Post", + raw: "NO (posting not allowed on this list)", + urls: []*url.URL{nil}, + }, + { + header: "List-Owner", + raw: " (Contact Person for Help)", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "listmom@host.com"}, + }, + }, + { + header: "List-Owner", + raw: " (Grant Neufeld)", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "grant@foo.bar"}, + }, + }, + { + header: "List-Owner", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "josh@foo.bar", RawQuery: "Subject=list"}, + }, + }, + { + header: "List-Archive", + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "archive@host.com", RawQuery: "subject=index%20list"}, + }, + }, + { + header: "List-Archive", + raw: "", + urls: []*url.URL{ + {Scheme: "ftp", Host: "ftp.host.com", Path: "/pub/list/archive/"}, + }, + }, + { + header: "List-Archive", + raw: " (Web Archive)", + urls: []*url.URL{ + {Scheme: "http", Host: "www.host.com", Path: "/list/archive/"}, + }, + }, + } + + for _, test := range tests { + var h mail.Header + h.Set(test.header, test.raw) + + urls, err := h.ListCommandURLList(test.header) + if err != nil && !test.xfail { + t.Errorf("Failed to parse %s %q: Header.ListCommandURLList() = %v", test.header, test.raw, err) + } else if !reflect.DeepEqual(urls, test.urls) { + t.Errorf("Failed to parse %s %q: Header.ListCommandURLList() = %q, want %q", test.header, test.raw, urls, test.urls) + } + } +} + +func TestHeader_SetListCommandURLList(t *testing.T) { + tests := []struct { + raw string + urls []*url.URL + }{ + { + raw: "", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list@example.com"}, + }, + }, + { + raw: ", ", + urls: []*url.URL{ + {Scheme: "mailto", Opaque: "list@example.com"}, + {Scheme: "https", Host: "example.com:8080"}, + }, + }, + } + for _, test := range tests { + var h mail.Header + h.SetListCommandURLList("List-Post", test.urls) + raw := h.Get("List-Post") + if raw != test.raw { + t.Errorf("Failed to format List-Post %q: Header.Get() = %q, want %q", test.urls, raw, test.raw) + } + } +}