Skip to content

Commit 960e249

Browse files
committed
Add support for RFC2369
Exposes parsed URLs from the various list commands described in RFC2369.
1 parent f80b76e commit 960e249

File tree

2 files changed

+319
-0
lines changed

2 files changed

+319
-0
lines changed

mail/header.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"net/mail"
9+
"net/url"
910
"os"
1011
"strconv"
1112
"strings"
@@ -211,6 +212,61 @@ func (p *headerParser) parseMsgID() (string, error) {
211212
return left + "@" + right, nil
212213
}
213214

215+
func (p *headerParser) parseListCommand() (*url.URL, error) {
216+
if !p.skipCFWS() {
217+
return nil, errors.New("mail: malformed parenthetical comment")
218+
}
219+
220+
// Consume a potential newline + indent.
221+
p.consume('\r')
222+
p.consume('\n')
223+
p.skipSpace()
224+
225+
if p.consume('N') && p.consume('O') {
226+
if !p.skipCFWS() {
227+
return nil, errors.New("mail: malformed parenthetical comment")
228+
}
229+
230+
return nil, nil
231+
}
232+
233+
if !p.consume('<') {
234+
return nil, errors.New("mail: missing '<' in list command")
235+
}
236+
237+
i := 0
238+
for p.s[i] != '>' && i+1 < len(p.s) {
239+
i += 1
240+
}
241+
242+
var lit string
243+
lit, p.s = p.s[:i], p.s[i:]
244+
245+
u, err := url.Parse(lit)
246+
if err != nil {
247+
return u, errors.New("mail: malformed URL")
248+
}
249+
250+
if !p.consume('>') {
251+
return nil, errors.New("mail: missing '>' in list command")
252+
}
253+
254+
if !p.skipCFWS() {
255+
return nil, errors.New("mail: malformed parenthetical comment")
256+
}
257+
258+
// If there isn't a comma, we don't care because it means that there aren't
259+
// any other list command URLs.
260+
p.consume(',')
261+
p.skipSpace()
262+
263+
// Consume a potential newline.
264+
p.consume('\r')
265+
p.consume('\n')
266+
267+
return u, nil
268+
}
269+
214270
// A Header is a mail header.
215271
type Header struct {
216272
message.Header
@@ -308,6 +364,35 @@ func (h *Header) MsgIDList(key string) ([]string, error) {
308364
return l, nil
309365
}
310366

367+
// MsgIDList parses a list of URLs from a list command header. It returns URLs.
368+
// If the header field is missing, it returns nil.
369+
//
370+
// This can be used on List-Help, List-Unsubscribe, List-Subscribe, List-Post,
371+
// List-Owner, and List-Archive headers.
372+
//
373+
// See https://www.rfc-editor.org/rfc/rfc2369 for more information.
374+
//
375+
// In the case that the value of List-Post is the special value, "NO", the
376+
// return value is a slice containing one element, nil.
377+
func (h *Header) ListCommandURLList(key string) ([]*url.URL, error) {
378+
v := h.Get(key)
379+
if v == "" {
380+
return nil, nil
381+
}
382+
383+
p := headerParser{v}
384+
var l []*url.URL
385+
for !p.empty() {
386+
url, err := p.parseListCommand()
387+
if err != nil {
388+
return l, err
389+
}
390+
l = append(l, url)
391+
}
392+
393+
return l, nil
394+
}
395+
311396
// GenerateMessageID wraps GenerateMessageIDWithHostname and therefore uses the
312397
// hostname of the local machine. This is done to not break existing software.
313398
// Wherever possible better use GenerateMessageIDWithHostname, because the local
@@ -362,6 +447,18 @@ func (h *Header) SetMsgIDList(key string, l []string) {
362447
}
363448
}
364449

450+
func (h *Header) SetListCommandURLList(key string, urls []*url.URL) {
451+
if len(urls) == 0 {
452+
h.Del(key)
453+
}
454+
455+
var ids []string
456+
for _, url := range urls {
457+
ids = append(ids, url.String())
458+
}
459+
h.Set(key, "<"+strings.Join(ids, ">, <")+">")
460+
}
461+
365462
// Copy creates a stand-alone copy of the header.
366463
func (h *Header) Copy() Header {
367464
return Header{h.Header.Copy()}

mail/header_test.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
netmail "net/mail"
7+
"net/url"
78
"reflect"
89
"strings"
910
"testing"
@@ -215,3 +216,224 @@ func TestHeader_EmptyAddressList(t *testing.T) {
215216
}
216217

217218
}
219+
220+
func TestHeader_ListCommandURLList(t *testing.T) {
221+
tests := []struct {
222+
header string
223+
raw string
224+
urls []*url.URL
225+
xfail bool
226+
}{
227+
{
228+
header: "List-Help",
229+
raw: "<mailto:[email protected]",
230+
xfail: true,
231+
},
232+
// These tests might seem repetitive, but they are the examples given at
233+
// https://www.rfc-editor.org/rfc/rfc2369.
234+
{
235+
header: "List-Help",
236+
raw: "<mailto:[email protected]?subject=help> (List Instructions)",
237+
urls: []*url.URL{
238+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "subject=help"},
239+
},
240+
},
241+
{
242+
header: "List-Help",
243+
raw: "<mailto:[email protected]?body=info>",
244+
urls: []*url.URL{
245+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "body=info"},
246+
},
247+
},
248+
{
249+
header: "List-Help",
250+
raw: "<mailto:[email protected]> (Info about the list)",
251+
urls: []*url.URL{
252+
{Scheme: "mailto", Opaque: "[email protected]"},
253+
},
254+
},
255+
{
256+
header: "List-Help",
257+
raw: "<http://www.host.com/list/>, <mailto:[email protected]>",
258+
urls: []*url.URL{
259+
{Scheme: "http", Host: "www.host.com", Path: "/list/"},
260+
{Scheme: "mailto", Opaque: "[email protected]"},
261+
},
262+
},
263+
{
264+
header: "List-Help",
265+
raw: "<ftp://ftp.host.com/list.txt> (FTP),\r\n\t<mailto:[email protected]?subject=help>",
266+
urls: []*url.URL{
267+
{Scheme: "ftp", Host: "ftp.host.com", Path: "/list.txt"},
268+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "subject=help"},
269+
},
270+
},
271+
{
272+
header: "List-Unsubscribe",
273+
raw: "<mailto:[email protected]?subject=unsubscribe>",
274+
urls: []*url.URL{
275+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "subject=unsubscribe"},
276+
},
277+
},
278+
{
279+
header: "List-Unsubscribe",
280+
raw: "(Use this command to get off the list)\r\n\t<mailto:[email protected]?body=unsubscribe%20list>",
281+
urls: []*url.URL{
282+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "body=unsubscribe%20list"},
283+
},
284+
},
285+
{
286+
header: "List-Unsubscribe",
287+
raw: "<mailto:[email protected]>",
288+
urls: []*url.URL{
289+
{Scheme: "mailto", Opaque: "[email protected]"},
290+
},
291+
},
292+
{
293+
header: "List-Unsubscribe",
294+
raw: "<http://www.host.com/list.cgi?cmd=unsub&lst=list>,\r\n\t<mailto:[email protected]?subject=unsubscribe>",
295+
urls: []*url.URL{
296+
{Scheme: "http", Host: "www.host.com", Path: "/list.cgi", RawQuery: "cmd=unsub&lst=list"},
297+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "subject=unsubscribe"},
298+
},
299+
},
300+
{
301+
header: "List-Subscribe",
302+
raw: "<mailto:[email protected]?subject=subscribe>",
303+
urls: []*url.URL{
304+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "subject=subscribe"},
305+
},
306+
},
307+
{
308+
header: "List-Subscribe",
309+
raw: "(Use this command to join the list)\r\n\t<mailto:[email protected]?body=subscribe%20list>",
310+
urls: []*url.URL{
311+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "body=subscribe%20list"},
312+
},
313+
},
314+
{
315+
header: "List-Unsubscribe",
316+
raw: "<mailto:[email protected]>",
317+
urls: []*url.URL{
318+
{Scheme: "mailto", Opaque: "[email protected]"},
319+
},
320+
},
321+
{
322+
header: "List-Subscribe",
323+
raw: "<http://www.host.com/list.cgi?cmd=sub&lst=list>,\r\n\t<mailto:[email protected]?subject=subscribe>",
324+
urls: []*url.URL{
325+
{Scheme: "http", Host: "www.host.com", Path: "/list.cgi", RawQuery: "cmd=sub&lst=list"},
326+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "subject=subscribe"},
327+
},
328+
},
329+
{
330+
header: "List-Post",
331+
raw: "<mailto:[email protected]>",
332+
urls: []*url.URL{
333+
{Scheme: "mailto", Opaque: "[email protected]"},
334+
},
335+
},
336+
{
337+
header: "List-Post",
338+
raw: "<mailto:[email protected]> (Postings are Moderated)",
339+
urls: []*url.URL{
340+
{Scheme: "mailto", Opaque: "[email protected]"},
341+
},
342+
},
343+
{
344+
header: "List-Post",
345+
raw: "<mailto:[email protected]?subject=list%20posting>",
346+
urls: []*url.URL{
347+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "subject=list%20posting"},
348+
},
349+
},
350+
{
351+
header: "List-Post",
352+
raw: "NO (posting not allowed on this list)",
353+
urls: []*url.URL{nil},
354+
},
355+
{
356+
header: "List-Owner",
357+
raw: "<mailto:[email protected]> (Contact Person for Help)",
358+
urls: []*url.URL{
359+
{Scheme: "mailto", Opaque: "[email protected]"},
360+
},
361+
},
362+
{
363+
header: "List-Owner",
364+
raw: "<mailto:[email protected]> (Grant Neufeld)",
365+
urls: []*url.URL{
366+
{Scheme: "mailto", Opaque: "[email protected]"},
367+
},
368+
},
369+
{
370+
header: "List-Owner",
371+
raw: "<mailto:[email protected]?Subject=list>",
372+
urls: []*url.URL{
373+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "Subject=list"},
374+
},
375+
},
376+
{
377+
header: "List-Archive",
378+
raw: "<mailto:[email protected]?subject=index%20list>",
379+
urls: []*url.URL{
380+
{Scheme: "mailto", Opaque: "[email protected]", RawQuery: "subject=index%20list"},
381+
},
382+
},
383+
{
384+
header: "List-Archive",
385+
raw: "<ftp://ftp.host.com/pub/list/archive/>",
386+
urls: []*url.URL{
387+
{Scheme: "ftp", Host: "ftp.host.com", Path: "/pub/list/archive/"},
388+
},
389+
},
390+
{
391+
header: "List-Archive",
392+
raw: "<http://www.host.com/list/archive/> (Web Archive)",
393+
urls: []*url.URL{
394+
{Scheme: "http", Host: "www.host.com", Path: "/list/archive/"},
395+
},
396+
},
397+
}
398+
399+
for _, test := range tests {
400+
var h mail.Header
401+
h.Set(test.header, test.raw)
402+
403+
urls, err := h.ListCommandURLList(test.header)
404+
if err != nil && !test.xfail {
405+
t.Errorf("Failed to parse %s %q: Header.ListCommandURLList() = %v", test.header, test.raw, err)
406+
} else if !reflect.DeepEqual(urls, test.urls) {
407+
t.Errorf("Failed to parse %s %q: Header.ListCommandURLList() = %q, want %q", test.header, test.raw, urls, test.urls)
408+
}
409+
}
410+
}
411+
412+
func TestHeader_SetListCommandURLList(t *testing.T) {
413+
tests := []struct {
414+
raw string
415+
urls []*url.URL
416+
}{
417+
{
418+
raw: "<mailto:[email protected]>",
419+
urls: []*url.URL{
420+
{Scheme: "mailto", Opaque: "[email protected]"},
421+
},
422+
},
423+
{
424+
raw: "<mailto:[email protected]>, <https://example.com:8080>",
425+
urls: []*url.URL{
426+
{Scheme: "mailto", Opaque: "[email protected]"},
427+
{Scheme: "https", Host: "example.com:8080"},
428+
},
429+
},
430+
}
431+
for _, test := range tests {
432+
var h mail.Header
433+
h.SetListCommandURLList("List-Post", test.urls)
434+
raw := h.Get("List-Post")
435+
if raw != test.raw {
436+
t.Errorf("Failed to format List-Post %q: Header.Get() = %q, want %q", test.urls, raw, test.raw)
437+
}
438+
}
439+
}

0 commit comments

Comments
 (0)