Skip to content

Commit 29e3f41

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

File tree

2 files changed

+313
-0
lines changed

2 files changed

+313
-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: 216 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,218 @@ func TestHeader_EmptyAddressList(t *testing.T) {
215216
}
216217

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

0 commit comments

Comments
 (0)