123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838 |
- // Copyright 2011 The Go Authors. All rights reserved.
- // Use of this source code is governed by a BSD-style
- // license that can be found in the LICENSE file.
- /*
- Package mail implements parsing of mail messages.
- For the most part, this package follows the syntax as specified by RFC 5322 and
- extended by RFC 6532.
- Notable divergences:
- * Obsolete address formats are not parsed, including addresses with
- embedded route information.
- * The full range of spacing (the CFWS syntax element) is not supported,
- such as breaking addresses across lines.
- * No unicode normalization is performed.
- * The special characters ()[]:;@\, are allowed to appear unquoted in names.
- */
- package mail
- import (
- "bufio"
- "errors"
- "fmt"
- "io"
- "log"
- "mime"
- "net/textproto"
- "strings"
- "sync"
- "time"
- "unicode/utf8"
- )
- var debug = debugT(false)
- type debugT bool
- func (d debugT) Printf(format string, args ...any) {
- if d {
- log.Printf(format, args...)
- }
- }
- // A Message represents a parsed mail message.
- type Message struct {
- Header Header
- Body io.Reader
- }
- // ReadMessage reads a message from r.
- // The headers are parsed, and the body of the message will be available
- // for reading from msg.Body.
- func ReadMessage(r io.Reader) (msg *Message, err error) {
- tp := textproto.NewReader(bufio.NewReader(r))
- hdr, err := tp.ReadMIMEHeader()
- if err != nil {
- return nil, err
- }
- return &Message{
- Header: Header(hdr),
- Body: tp.R,
- }, nil
- }
- // Layouts suitable for passing to time.Parse.
- // These are tried in order.
- var (
- dateLayoutsBuildOnce sync.Once
- dateLayouts []string
- )
- func buildDateLayouts() {
- // Generate layouts based on RFC 5322, section 3.3.
- dows := [...]string{"", "Mon, "} // day-of-week
- days := [...]string{"2", "02"} // day = 1*2DIGIT
- years := [...]string{"2006", "06"} // year = 4*DIGIT / 2*DIGIT
- seconds := [...]string{":05", ""} // second
- // "-0700 (MST)" is not in RFC 5322, but is common.
- zones := [...]string{"-0700", "MST"} // zone = (("+" / "-") 4DIGIT) / "GMT" / ...
- for _, dow := range dows {
- for _, day := range days {
- for _, year := range years {
- for _, second := range seconds {
- for _, zone := range zones {
- s := dow + day + " Jan " + year + " 15:04" + second + " " + zone
- dateLayouts = append(dateLayouts, s)
- }
- }
- }
- }
- }
- }
- // ParseDate parses an RFC 5322 date string.
- func ParseDate(date string) (time.Time, error) {
- dateLayoutsBuildOnce.Do(buildDateLayouts)
- // CR and LF must match and are tolerated anywhere in the date field.
- date = strings.ReplaceAll(date, "\r\n", "")
- if strings.Contains(date, "\r") {
- return time.Time{}, errors.New("mail: header has a CR without LF")
- }
- // Re-using some addrParser methods which support obsolete text, i.e. non-printable ASCII
- p := addrParser{date, nil}
- p.skipSpace()
- // RFC 5322: zone = (FWS ( "+" / "-" ) 4DIGIT) / obs-zone
- // zone length is always 5 chars unless obsolete (obs-zone)
- if ind := strings.IndexAny(p.s, "+-"); ind != -1 && len(p.s) >= ind+5 {
- date = p.s[:ind+5]
- p.s = p.s[ind+5:]
- } else {
- ind := strings.Index(p.s, "T")
- if ind == 0 {
- // In this case we have the following date formats:
- // * Thu, 20 Nov 1997 09:55:06 MDT
- // * Thu, 20 Nov 1997 09:55:06 MDT (MDT)
- // * Thu, 20 Nov 1997 09:55:06 MDT (This comment)
- ind = strings.Index(p.s[1:], "T")
- if ind != -1 {
- ind++
- }
- }
- if ind != -1 && len(p.s) >= ind+5 {
- // The last letter T of the obsolete time zone is checked when no standard time zone is found.
- // If T is misplaced, the date to parse is garbage.
- date = p.s[:ind+1]
- p.s = p.s[ind+1:]
- }
- }
- if !p.skipCFWS() {
- return time.Time{}, errors.New("mail: misformatted parenthetical comment")
- }
- for _, layout := range dateLayouts {
- t, err := time.Parse(layout, date)
- if err == nil {
- return t, nil
- }
- }
- return time.Time{}, errors.New("mail: header could not be parsed")
- }
- // A Header represents the key-value pairs in a mail message header.
- type Header map[string][]string
- // Get gets the first value associated with the given key.
- // It is case insensitive; CanonicalMIMEHeaderKey is used
- // to canonicalize the provided key.
- // If there are no values associated with the key, Get returns "".
- // To access multiple values of a key, or to use non-canonical keys,
- // access the map directly.
- func (h Header) Get(key string) string {
- return textproto.MIMEHeader(h).Get(key)
- }
- var ErrHeaderNotPresent = errors.New("mail: header not in message")
- // Date parses the Date header field.
- func (h Header) Date() (time.Time, error) {
- hdr := h.Get("Date")
- if hdr == "" {
- return time.Time{}, ErrHeaderNotPresent
- }
- return ParseDate(hdr)
- }
- // AddressList parses the named header field as a list of addresses.
- func (h Header) AddressList(key string) ([]*Address, error) {
- hdr := h.Get(key)
- if hdr == "" {
- return nil, ErrHeaderNotPresent
- }
- return ParseAddressList(hdr)
- }
- // Address represents a single mail address.
- // An address such as "Barry Gibbs <bg@example.com>" is represented
- // as Address{Name: "Barry Gibbs", Address: "bg@example.com"}.
- type Address struct {
- Name string // Proper name; may be empty.
- Address string // user@domain
- }
- // ParseAddress parses a single RFC 5322 address, e.g. "Barry Gibbs <bg@example.com>"
- func ParseAddress(address string) (*Address, error) {
- return (&addrParser{s: address}).parseSingleAddress()
- }
- // ParseAddressList parses the given string as a list of addresses.
- func ParseAddressList(list string) ([]*Address, error) {
- return (&addrParser{s: list}).parseAddressList()
- }
- // An AddressParser is an RFC 5322 address parser.
- type AddressParser struct {
- // WordDecoder optionally specifies a decoder for RFC 2047 encoded-words.
- WordDecoder *mime.WordDecoder
- }
- // Parse parses a single RFC 5322 address of the
- // form "Gogh Fir <gf@example.com>" or "foo@example.com".
- func (p *AddressParser) Parse(address string) (*Address, error) {
- return (&addrParser{s: address, dec: p.WordDecoder}).parseSingleAddress()
- }
- // ParseList parses the given string as a list of comma-separated addresses
- // of the form "Gogh Fir <gf@example.com>" or "foo@example.com".
- func (p *AddressParser) ParseList(list string) ([]*Address, error) {
- return (&addrParser{s: list, dec: p.WordDecoder}).parseAddressList()
- }
- // String formats the address as a valid RFC 5322 address.
- // If the address's name contains non-ASCII characters
- // the name will be rendered according to RFC 2047.
- func (a *Address) String() string {
- // Format address local@domain
- at := strings.LastIndex(a.Address, "@")
- var local, domain string
- if at < 0 {
- // This is a malformed address ("@" is required in addr-spec);
- // treat the whole address as local-part.
- local = a.Address
- } else {
- local, domain = a.Address[:at], a.Address[at+1:]
- }
- // Add quotes if needed
- quoteLocal := false
- for i, r := range local {
- if isAtext(r, false, false) {
- continue
- }
- if r == '.' {
- // Dots are okay if they are surrounded by atext.
- // We only need to check that the previous byte is
- // not a dot, and this isn't the end of the string.
- if i > 0 && local[i-1] != '.' && i < len(local)-1 {
- continue
- }
- }
- quoteLocal = true
- break
- }
- if quoteLocal {
- local = quoteString(local)
- }
- s := "<" + local + "@" + domain + ">"
- if a.Name == "" {
- return s
- }
- // If every character is printable ASCII, quoting is simple.
- allPrintable := true
- for _, r := range a.Name {
- // isWSP here should actually be isFWS,
- // but we don't support folding yet.
- if !isVchar(r) && !isWSP(r) || isMultibyte(r) {
- allPrintable = false
- break
- }
- }
- if allPrintable {
- return quoteString(a.Name) + " " + s
- }
- // Text in an encoded-word in a display-name must not contain certain
- // characters like quotes or parentheses (see RFC 2047 section 5.3).
- // When this is the case encode the name using base64 encoding.
- if strings.ContainsAny(a.Name, "\"#$%&'(),.:;<>@[]^`{|}~") {
- return mime.BEncoding.Encode("utf-8", a.Name) + " " + s
- }
- return mime.QEncoding.Encode("utf-8", a.Name) + " " + s
- }
- type addrParser struct {
- s string
- dec *mime.WordDecoder // may be nil
- }
- func (p *addrParser) parseAddressList() ([]*Address, error) {
- var list []*Address
- for {
- p.skipSpace()
- // allow skipping empty entries (RFC5322 obs-addr-list)
- if p.consume(',') {
- continue
- }
- addrs, err := p.parseAddress(true)
- if err != nil {
- return nil, err
- }
- list = append(list, addrs...)
- if !p.skipCFWS() {
- return nil, errors.New("mail: misformatted parenthetical comment")
- }
- if p.empty() {
- break
- }
- if p.peek() != ',' {
- return nil, errors.New("mail: expected comma")
- }
- // Skip empty entries for obs-addr-list.
- for p.consume(',') {
- p.skipSpace()
- }
- if p.empty() {
- break
- }
- }
- return list, nil
- }
- func (p *addrParser) parseSingleAddress() (*Address, error) {
- addrs, err := p.parseAddress(true)
- if err != nil {
- return nil, err
- }
- if !p.skipCFWS() {
- return nil, errors.New("mail: misformatted parenthetical comment")
- }
- if !p.empty() {
- return nil, fmt.Errorf("mail: expected single address, got %q", p.s)
- }
- if len(addrs) == 0 {
- return nil, errors.New("mail: empty group")
- }
- if len(addrs) > 1 {
- return nil, errors.New("mail: group with multiple addresses")
- }
- return addrs[0], nil
- }
- // parseAddress parses a single RFC 5322 address at the start of p.
- func (p *addrParser) parseAddress(handleGroup bool) ([]*Address, error) {
- debug.Printf("parseAddress: %q", p.s)
- p.skipSpace()
- if p.empty() {
- return nil, errors.New("mail: no address")
- }
- // address = mailbox / group
- // mailbox = name-addr / addr-spec
- // group = display-name ":" [group-list] ";" [CFWS]
- // addr-spec has a more restricted grammar than name-addr,
- // so try parsing it first, and fallback to name-addr.
- // TODO(dsymonds): Is this really correct?
- spec, err := p.consumeAddrSpec()
- if err == nil {
- var displayName string
- p.skipSpace()
- if !p.empty() && p.peek() == '(' {
- displayName, err = p.consumeDisplayNameComment()
- if err != nil {
- return nil, err
- }
- }
- return []*Address{{
- Name: displayName,
- Address: spec,
- }}, err
- }
- debug.Printf("parseAddress: not an addr-spec: %v", err)
- debug.Printf("parseAddress: state is now %q", p.s)
- // display-name
- var displayName string
- if p.peek() != '<' {
- displayName, err = p.consumePhrase()
- if err != nil {
- return nil, err
- }
- }
- debug.Printf("parseAddress: displayName=%q", displayName)
- p.skipSpace()
- if handleGroup {
- if p.consume(':') {
- return p.consumeGroupList()
- }
- }
- // angle-addr = "<" addr-spec ">"
- if !p.consume('<') {
- atext := true
- for _, r := range displayName {
- if !isAtext(r, true, false) {
- atext = false
- break
- }
- }
- if atext {
- // The input is like "foo.bar"; it's possible the input
- // meant to be "foo.bar@domain", or "foo.bar <...>".
- return nil, errors.New("mail: missing '@' or angle-addr")
- }
- // The input is like "Full Name", which couldn't possibly be a
- // valid email address if followed by "@domain"; the input
- // likely meant to be "Full Name <...>".
- return nil, errors.New("mail: no angle-addr")
- }
- spec, err = p.consumeAddrSpec()
- if err != nil {
- return nil, err
- }
- if !p.consume('>') {
- return nil, errors.New("mail: unclosed angle-addr")
- }
- debug.Printf("parseAddress: spec=%q", spec)
- return []*Address{{
- Name: displayName,
- Address: spec,
- }}, nil
- }
- func (p *addrParser) consumeGroupList() ([]*Address, error) {
- var group []*Address
- // handle empty group.
- p.skipSpace()
- if p.consume(';') {
- p.skipCFWS()
- return group, nil
- }
- for {
- p.skipSpace()
- // embedded groups not allowed.
- addrs, err := p.parseAddress(false)
- if err != nil {
- return nil, err
- }
- group = append(group, addrs...)
- if !p.skipCFWS() {
- return nil, errors.New("mail: misformatted parenthetical comment")
- }
- if p.consume(';') {
- p.skipCFWS()
- break
- }
- if !p.consume(',') {
- return nil, errors.New("mail: expected comma")
- }
- }
- return group, nil
- }
- // consumeAddrSpec parses a single RFC 5322 addr-spec at the start of p.
- func (p *addrParser) consumeAddrSpec() (spec string, err error) {
- debug.Printf("consumeAddrSpec: %q", p.s)
- orig := *p
- defer func() {
- if err != nil {
- *p = orig
- }
- }()
- // local-part = dot-atom / quoted-string
- var localPart string
- p.skipSpace()
- if p.empty() {
- return "", errors.New("mail: no addr-spec")
- }
- if p.peek() == '"' {
- // quoted-string
- debug.Printf("consumeAddrSpec: parsing quoted-string")
- localPart, err = p.consumeQuotedString()
- if localPart == "" {
- err = errors.New("mail: empty quoted string in addr-spec")
- }
- } else {
- // dot-atom
- debug.Printf("consumeAddrSpec: parsing dot-atom")
- localPart, err = p.consumeAtom(true, false)
- }
- if err != nil {
- debug.Printf("consumeAddrSpec: failed: %v", err)
- return "", err
- }
- if !p.consume('@') {
- return "", errors.New("mail: missing @ in addr-spec")
- }
- // domain = dot-atom / domain-literal
- var domain string
- p.skipSpace()
- if p.empty() {
- return "", errors.New("mail: no domain in addr-spec")
- }
- // TODO(dsymonds): Handle domain-literal
- domain, err = p.consumeAtom(true, false)
- if err != nil {
- return "", err
- }
- return localPart + "@" + domain, nil
- }
- // consumePhrase parses the RFC 5322 phrase at the start of p.
- func (p *addrParser) consumePhrase() (phrase string, err error) {
- debug.Printf("consumePhrase: [%s]", p.s)
- // phrase = 1*word
- var words []string
- var isPrevEncoded bool
- for {
- // word = atom / quoted-string
- var word string
- p.skipSpace()
- if p.empty() {
- break
- }
- isEncoded := false
- if p.peek() == '"' {
- // quoted-string
- word, err = p.consumeQuotedString()
- } else {
- // atom
- // We actually parse dot-atom here to be more permissive
- // than what RFC 5322 specifies.
- word, err = p.consumeAtom(true, true)
- if err == nil {
- word, isEncoded, err = p.decodeRFC2047Word(word)
- }
- }
- if err != nil {
- break
- }
- debug.Printf("consumePhrase: consumed %q", word)
- if isPrevEncoded && isEncoded {
- words[len(words)-1] += word
- } else {
- words = append(words, word)
- }
- isPrevEncoded = isEncoded
- }
- // Ignore any error if we got at least one word.
- if err != nil && len(words) == 0 {
- debug.Printf("consumePhrase: hit err: %v", err)
- return "", fmt.Errorf("mail: missing word in phrase: %v", err)
- }
- phrase = strings.Join(words, " ")
- return phrase, nil
- }
- // consumeQuotedString parses the quoted string at the start of p.
- func (p *addrParser) consumeQuotedString() (qs string, err error) {
- // Assume first byte is '"'.
- i := 1
- qsb := make([]rune, 0, 10)
- escaped := false
- Loop:
- for {
- r, size := utf8.DecodeRuneInString(p.s[i:])
- switch {
- case size == 0:
- return "", errors.New("mail: unclosed quoted-string")
- case size == 1 && r == utf8.RuneError:
- return "", fmt.Errorf("mail: invalid utf-8 in quoted-string: %q", p.s)
- case escaped:
- // quoted-pair = ("\" (VCHAR / WSP))
- if !isVchar(r) && !isWSP(r) {
- return "", fmt.Errorf("mail: bad character in quoted-string: %q", r)
- }
- qsb = append(qsb, r)
- escaped = false
- case isQtext(r) || isWSP(r):
- // qtext (printable US-ASCII excluding " and \), or
- // FWS (almost; we're ignoring CRLF)
- qsb = append(qsb, r)
- case r == '"':
- break Loop
- case r == '\\':
- escaped = true
- default:
- return "", fmt.Errorf("mail: bad character in quoted-string: %q", r)
- }
- i += size
- }
- p.s = p.s[i+1:]
- return string(qsb), nil
- }
- // consumeAtom parses an RFC 5322 atom at the start of p.
- // If dot is true, consumeAtom parses an RFC 5322 dot-atom instead.
- // If permissive is true, consumeAtom will not fail on:
- // - leading/trailing/double dots in the atom (see golang.org/issue/4938)
- // - special characters (RFC 5322 3.2.3) except '<', '>', ':' and '"' (see golang.org/issue/21018)
- func (p *addrParser) consumeAtom(dot bool, permissive bool) (atom string, err error) {
- i := 0
- Loop:
- for {
- r, size := utf8.DecodeRuneInString(p.s[i:])
- switch {
- case size == 1 && r == utf8.RuneError:
- return "", fmt.Errorf("mail: invalid utf-8 in address: %q", p.s)
- case size == 0 || !isAtext(r, dot, permissive):
- break Loop
- default:
- i += size
- }
- }
- if i == 0 {
- return "", errors.New("mail: invalid string")
- }
- atom, p.s = p.s[:i], p.s[i:]
- if !permissive {
- if strings.HasPrefix(atom, ".") {
- return "", errors.New("mail: leading dot in atom")
- }
- if strings.Contains(atom, "..") {
- return "", errors.New("mail: double dot in atom")
- }
- if strings.HasSuffix(atom, ".") {
- return "", errors.New("mail: trailing dot in atom")
- }
- }
- return atom, nil
- }
- func (p *addrParser) consumeDisplayNameComment() (string, error) {
- if !p.consume('(') {
- return "", errors.New("mail: comment does not start with (")
- }
- comment, ok := p.consumeComment()
- if !ok {
- return "", errors.New("mail: misformatted parenthetical comment")
- }
- // TODO(stapelberg): parse quoted-string within comment
- words := strings.FieldsFunc(comment, func(r rune) bool { return r == ' ' || r == '\t' })
- for idx, word := range words {
- decoded, isEncoded, err := p.decodeRFC2047Word(word)
- if err != nil {
- return "", err
- }
- if isEncoded {
- words[idx] = decoded
- }
- }
- return strings.Join(words, " "), nil
- }
- func (p *addrParser) consume(c byte) bool {
- if p.empty() || p.peek() != c {
- return false
- }
- p.s = p.s[1:]
- return true
- }
- // skipSpace skips the leading space and tab characters.
- func (p *addrParser) skipSpace() {
- p.s = strings.TrimLeft(p.s, " \t")
- }
- func (p *addrParser) peek() byte {
- return p.s[0]
- }
- func (p *addrParser) empty() bool {
- return p.len() == 0
- }
- func (p *addrParser) len() int {
- return len(p.s)
- }
- // skipCFWS skips CFWS as defined in RFC5322.
- func (p *addrParser) skipCFWS() bool {
- p.skipSpace()
- for {
- if !p.consume('(') {
- break
- }
- if _, ok := p.consumeComment(); !ok {
- return false
- }
- p.skipSpace()
- }
- return true
- }
- func (p *addrParser) consumeComment() (string, bool) {
- // '(' already consumed.
- depth := 1
- var comment string
- for {
- if p.empty() || depth == 0 {
- break
- }
- if p.peek() == '\\' && p.len() > 1 {
- p.s = p.s[1:]
- } else if p.peek() == '(' {
- depth++
- } else if p.peek() == ')' {
- depth--
- }
- if depth > 0 {
- comment += p.s[:1]
- }
- p.s = p.s[1:]
- }
- return comment, depth == 0
- }
- func (p *addrParser) decodeRFC2047Word(s string) (word string, isEncoded bool, err error) {
- if p.dec != nil {
- word, err = p.dec.Decode(s)
- } else {
- word, err = rfc2047Decoder.Decode(s)
- }
- if err == nil {
- return word, true, nil
- }
- if _, ok := err.(charsetError); ok {
- return s, true, err
- }
- // Ignore invalid RFC 2047 encoded-word errors.
- return s, false, nil
- }
- var rfc2047Decoder = mime.WordDecoder{
- CharsetReader: func(charset string, input io.Reader) (io.Reader, error) {
- return nil, charsetError(charset)
- },
- }
- type charsetError string
- func (e charsetError) Error() string {
- return fmt.Sprintf("charset not supported: %q", string(e))
- }
- // isAtext reports whether r is an RFC 5322 atext character.
- // If dot is true, period is included.
- // If permissive is true, RFC 5322 3.2.3 specials is included,
- // except '<', '>', ':' and '"'.
- func isAtext(r rune, dot, permissive bool) bool {
- switch r {
- case '.':
- return dot
- // RFC 5322 3.2.3. specials
- case '(', ')', '[', ']', ';', '@', '\\', ',':
- return permissive
- case '<', '>', '"', ':':
- return false
- }
- return isVchar(r)
- }
- // isQtext reports whether r is an RFC 5322 qtext character.
- func isQtext(r rune) bool {
- // Printable US-ASCII, excluding backslash or quote.
- if r == '\\' || r == '"' {
- return false
- }
- return isVchar(r)
- }
- // quoteString renders a string as an RFC 5322 quoted-string.
- func quoteString(s string) string {
- var buf strings.Builder
- buf.WriteByte('"')
- for _, r := range s {
- if isQtext(r) || isWSP(r) {
- buf.WriteRune(r)
- } else if isVchar(r) {
- buf.WriteByte('\\')
- buf.WriteRune(r)
- }
- }
- buf.WriteByte('"')
- return buf.String()
- }
- // isVchar reports whether r is an RFC 5322 VCHAR character.
- func isVchar(r rune) bool {
- // Visible (printing) characters.
- return '!' <= r && r <= '~' || isMultibyte(r)
- }
- // isMultibyte reports whether r is a multi-byte UTF-8 character
- // as supported by RFC 6532
- func isMultibyte(r rune) bool {
- return r >= utf8.RuneSelf
- }
- // isWSP reports whether r is a WSP (white space).
- // WSP is a space or horizontal tab (RFC 5234 Appendix B).
- func isWSP(r rune) bool {
- return r == ' ' || r == '\t'
- }
|