From 537c3abe82f72e2fdb0c373cc2b194e3098c1511 Mon Sep 17 00:00:00 2001 From: ChronosX88 Date: Mon, 21 Feb 2022 13:43:39 +0300 Subject: [PATCH] Implement advanced parsing of envelope (article) --- go.mod | 14 +- go.sum | 22 + internal/models/article.go | 2 + internal/server/handler.go | 79 ++-- internal/utils/builder.go | 226 ++++++++++ internal/utils/message.go | 635 ----------------------------- internal/utils/stringutil/addr.go | 21 + internal/utils/stringutil/split.go | 37 ++ internal/utils/stringutil/uuid.go | 24 ++ internal/utils/stringutil/wrap.go | 36 ++ 10 files changed, 425 insertions(+), 671 deletions(-) create mode 100644 internal/utils/builder.go delete mode 100644 internal/utils/message.go create mode 100644 internal/utils/stringutil/addr.go create mode 100644 internal/utils/stringutil/split.go create mode 100644 internal/utils/stringutil/uuid.go create mode 100644 internal/utils/stringutil/wrap.go diff --git a/go.mod b/go.mod index bc80f97..edbc1af 100644 --- a/go.mod +++ b/go.mod @@ -11,4 +11,16 @@ require ( github.com/pressly/goose/v3 v3.5.0 ) -require github.com/pkg/errors v0.9.1 // indirect +require ( + github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect + github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect + github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect + github.com/jhillyerd/enmime v0.9.3 // indirect + github.com/mattn/go-runewidth v0.0.12 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect + golang.org/x/text v0.3.6 // indirect +) diff --git a/go.sum b/go.sum index cf565ba..ea54eae 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M= github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= @@ -58,10 +60,13 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY= +github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -94,6 +99,10 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc= +github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jhillyerd/enmime v0.9.3 h1:XKqSbnX3AV+MbzM2NnhRlO6BBa123sPTdwtGl2pySik= +github.com/jhillyerd/enmime v0.9.3/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w= github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= @@ -120,6 +129,9 @@ github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= @@ -134,6 +146,8 @@ github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXy github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= @@ -161,6 +175,9 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= @@ -176,6 +193,8 @@ github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHN github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -220,6 +239,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -255,6 +276,7 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/models/article.go b/internal/models/article.go index 6a6ee08..af618d9 100644 --- a/internal/models/article.go +++ b/internal/models/article.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "github.com/jhillyerd/enmime" "net/textproto" "time" ) @@ -14,5 +15,6 @@ type Article struct { Thread sql.NullString `db:"thread"` Header textproto.MIMEHeader `db:"-"` + Envelope *enmime.Envelope `db:"-"` ArticleNumber int `db:"-"` } diff --git a/internal/server/handler.go b/internal/server/handler.go index 51f08b1..cf31def 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -11,7 +11,7 @@ import ( "github.com/ChronosX88/yans/internal/protocol" "github.com/ChronosX88/yans/internal/utils" "github.com/google/uuid" - "io" + "github.com/jhillyerd/enmime" "net/mail" "strconv" "strings" @@ -304,43 +304,42 @@ func (h *Handler) handlePost(s *Session, command string, arguments []string, id return err } - headers, err := s.tconn.ReadMIMEHeader() + dr := s.tconn.DotReader() + + envelope, err := enmime.ReadEnvelope(dr) if err != nil { return err } // generate message id messageID := fmt.Sprintf("<%s@%s>", uuid.New().String(), h.serverDomain) - headers.Set("Message-ID", messageID) + envelope.SetHeader("Message-ID", []string{messageID}) // set path header - headers.Set("Path", fmt.Sprintf("%s!not-for-mail", h.serverDomain)) + envelope.SetHeader("Path", []string{fmt.Sprintf("%s!not-for-mail", h.serverDomain)}) // set date header - if headers.Get("Date") == "" { - headers.Set("Date", time.Now().UTC().Format(time.RFC1123Z)) - } + envelope.AddHeader("Date", time.Now().UTC().Format(time.RFC1123Z)) - headerJson, err := json.Marshal(headers) + headerJson, err := json.Marshal(envelope.Root.Header) if err != nil { return err } a := models.Article{} a.HeaderRaw = string(headerJson) - a.Header = headers + a.Header = envelope.Root.Header + a.Envelope = envelope - dr := s.tconn.DotReader() // TODO handle multipart message - body, err := io.ReadAll(dr) if err != nil { return err } - a.Body = string(body) + a.Body = string(envelope.Text) // set thread property - if headers.Get("In-Reply-To") != "" { - parentMessage, err := h.backend.GetArticle(headers.Get("In-Reply-To")) + if envelope.GetHeader("In-Reply-To") != "" { + parentMessage, err := h.backend.GetArticle(envelope.GetHeader("In-Reply-To")) if err != nil { if err == sql.ErrNoRows { return s.tconn.PrintfLine(protocol.NNTPResponse{Code: 441, Message: "no such message you are replying to"}.String()) @@ -487,18 +486,19 @@ func (h *Handler) handleArticle(s *Session, command string, arguments []string, switch command { case protocol.CommandArticle: { - m := utils.NewMessage() - for k, v := range a.Header { - m.SetHeader(k, v...) - } - - m.SetBody("text/plain", a.Body) // FIXME currently only plain text is supported dw := s.tconn.DotWriter() - _, err = dw.Write([]byte(protocol.NNTPResponse{Code: 220, Message: fmt.Sprintf("%d %s", num, a.Header.Get("Message-ID"))}.String() + protocol.CRLF)) + builder := utils.Builder() + for k, v := range a.Header { + for _, j := range v { + builder = builder.Header(k, j) + } + } + builder = builder.Text([]byte(a.Body)) // FIXME currently only plain text is supported + p, err := builder.Build() if err != nil { return err } - _, err = m.WriteTo(dw) + err = p.Encode(dw) if err != nil { return err } @@ -507,9 +507,15 @@ func (h *Handler) handleArticle(s *Session, command string, arguments []string, } case protocol.CommandHead: { - m := utils.NewMessage() + builder := utils.Builder() for k, v := range a.Header { - m.SetHeader(k, v...) + for _, j := range v { + builder = builder.Header(k, j) + } + } + p, err := builder.Build() + if err != nil { + return err } dw := s.tconn.DotWriter() @@ -517,7 +523,7 @@ func (h *Handler) handleArticle(s *Session, command string, arguments []string, if err != nil { return err } - _, err = m.WriteTo(dw) + err = p.Encode(dw) if err != nil { return err } @@ -526,12 +532,6 @@ func (h *Handler) handleArticle(s *Session, command string, arguments []string, } case protocol.CommandBody: { - m := utils.NewMessage() - for k, v := range a.Header { - m.SetHeader(k, v...) - } - - m.SetBody("text/plain", a.Body) // FIXME currently only plain text is supported dw := s.tconn.DotWriter() _, err = dw.Write([]byte(protocol.NNTPResponse{Code: 222, Message: fmt.Sprintf("%d %s", num, a.Header.Get("Message-ID"))}.String() + protocol.CRLF)) @@ -812,13 +812,22 @@ func (h *Handler) handleOver(s *Session, command string, arguments []string, id dw.Write([]byte(v.Header.Get("References") + " ")) // count bytes for message - m := utils.NewMessage() + builder := utils.Builder() for k, v := range v.Header { - m.SetHeader(k, v...) + for _, j := range v { + builder = builder.Header(k, j) + } } - m.SetBody("text/plain", v.Body) // FIXME currently only plain text is supported + builder = builder.Text([]byte(v.Body)) // FIXME currently only plain text is supported b := bytes.NewBuffer([]byte{}) - m.WriteTo(b) + p, err := builder.Build() + if err != nil { + return err + } + err = p.Encode(b) + if err != nil { + return err + } bytesMetadata := b.Len() linesMetadata := strings.Count(v.Body, "\n") diff --git a/internal/utils/builder.go b/internal/utils/builder.go new file mode 100644 index 0000000..0348762 --- /dev/null +++ b/internal/utils/builder.go @@ -0,0 +1,226 @@ +package utils + +import ( + "github.com/jhillyerd/enmime" + "io/ioutil" + "mime" + "net/textproto" + "os" + "path/filepath" + "reflect" +) + +const ( + cdAttachment = "attachment" + cdInline = "inline" + + ctMultipartAltern = "multipart/alternative" + ctMultipartMixed = "multipart/mixed" + ctMultipartRelated = "multipart/related" + ctTextPlain = "text/plain" + ctTextHTML = "text/html" + + hnMIMEVersion = "MIME-Version" + + utf8 = "utf-8" +) + +// MailBuilder facilitates the easy construction of a MIME message. Each manipulation method +// returns a copy of the receiver struct. It can be considered immutable if the caller does not +// modify the string and byte slices passed in. Immutability allows the headers or entire message +// to be reused across multiple threads. +type MailBuilder struct { + header textproto.MIMEHeader + text, html []byte + inlines, attachments []*enmime.Part + err error +} + +// Builder returns an empty MailBuilder struct. +func Builder() MailBuilder { + return MailBuilder{} +} + +// Error returns the stored error from a file attachment/inline read or nil. +func (p MailBuilder) Error() error { + return p.err +} + +// Header returns a copy of MailBuilder with the specified value added to the named header. +func (p MailBuilder) Header(name, value string) MailBuilder { + // Copy existing header map + h := textproto.MIMEHeader{} + for k, v := range p.header { + h[k] = v + } + h.Add(name, value) + p.header = h + return p +} + +// Text returns a copy of MailBuilder that will use the provided bytes for its text/plain Part. +func (p MailBuilder) Text(body []byte) MailBuilder { + p.text = body + return p +} + +// HTML returns a copy of MailBuilder that will use the provided bytes for its text/html Part. +func (p MailBuilder) HTML(body []byte) MailBuilder { + p.html = body + return p +} + +// AddAttachment returns a copy of MailBuilder that includes the specified attachment. +func (p MailBuilder) AddAttachment(b []byte, contentType string, fileName string) MailBuilder { + part := enmime.NewPart(contentType) + part.Content = b + part.FileName = fileName + part.Disposition = cdAttachment + p.attachments = append(p.attachments, part) + return p +} + +// AddFileAttachment returns a copy of MailBuilder that includes the specified attachment. +// fileName, will be populated from the base name of path. Content type will be detected from the +// path extension. +func (p MailBuilder) AddFileAttachment(path string) MailBuilder { + // Only allow first p.err value + if p.err != nil { + return p + } + f, err := os.Open(path) + if err != nil { + p.err = err + return p + } + b, err := ioutil.ReadAll(f) + if err != nil { + p.err = err + return p + } + name := filepath.Base(path) + ctype := mime.TypeByExtension(filepath.Ext(name)) + return p.AddAttachment(b, ctype, name) +} + +// AddInline returns a copy of MailBuilder that includes the specified inline. fileName and +// contentID may be left empty. +func (p MailBuilder) AddInline( + b []byte, + contentType string, + fileName string, + contentID string, +) MailBuilder { + part := enmime.NewPart(contentType) + part.Content = b + part.FileName = fileName + part.Disposition = cdInline + part.ContentID = contentID + p.inlines = append(p.inlines, part) + return p +} + +// AddFileInline returns a copy of MailBuilder that includes the specified inline. fileName and +// contentID will be populated from the base name of path. Content type will be detected from the +// path extension. +func (p MailBuilder) AddFileInline(path string) MailBuilder { + // Only allow first p.err value + if p.err != nil { + return p + } + f, err := os.Open(path) + if err != nil { + p.err = err + return p + } + b, err := ioutil.ReadAll(f) + if err != nil { + p.err = err + return p + } + name := filepath.Base(path) + ctype := mime.TypeByExtension(filepath.Ext(name)) + return p.AddInline(b, ctype, name, name) +} + +// Build performs some basic validations, then constructs a tree of Part structs from the configured +// MailBuilder. It will set the Date header to now if it was not explicitly set. +func (p MailBuilder) Build() (*enmime.Part, error) { + if p.err != nil { + return nil, p.err + } + // Validations + // Fully loaded structure; the presence of text, html, inlines, and attachments will determine + // how much is necessary: + // + // multipart/mixed + // |- multipart/related + // | |- multipart/alternative + // | | |- text/plain + // | | `- text/html + // | `- inlines.. + // `- attachments.. + // + // We build this tree starting at the leaves, re-rooting as needed. + var root, part *enmime.Part + if p.text != nil || p.html == nil { + root = enmime.NewPart(ctTextPlain) + root.Content = p.text + root.Charset = utf8 + } + if p.html != nil { + part = enmime.NewPart(ctTextHTML) + part.Content = p.html + part.Charset = utf8 + if root == nil { + root = part + } else { + root.NextSibling = part + } + } + if p.text != nil && p.html != nil { + // Wrap Text & HTML bodies + part = root + root = enmime.NewPart(ctMultipartAltern) + root.AddChild(part) + } + if len(p.inlines) > 0 { + part = root + root = enmime.NewPart(ctMultipartRelated) + root.AddChild(part) + for _, ip := range p.inlines { + // Copy inline Part to isolate mutations + part = &enmime.Part{} + *part = *ip + part.Header = make(textproto.MIMEHeader) + root.AddChild(part) + } + } + if len(p.attachments) > 0 { + part = root + root = enmime.NewPart(ctMultipartMixed) + root.AddChild(part) + for _, ap := range p.attachments { + // Copy attachment Part to isolate mutations + part = &enmime.Part{} + *part = *ap + part.Header = make(textproto.MIMEHeader) + root.AddChild(part) + } + } + // Headers + h := root.Header + h.Set(hnMIMEVersion, "1.0") + for k, v := range p.header { + for _, s := range v { + h.Set(k, s) + } + } + return root, nil +} + +// Equals uses the reflect package to test two MailBuilder structs for equality, primarily for unit +// tests. +func (p MailBuilder) Equals(o MailBuilder) bool { + return reflect.DeepEqual(p, o) +} diff --git a/internal/utils/message.go b/internal/utils/message.go deleted file mode 100644 index 5a62c9c..0000000 --- a/internal/utils/message.go +++ /dev/null @@ -1,635 +0,0 @@ -package utils - -// this package has been kindly taken from https://github.com/go-gomail/gomail -// licensed under MIT license - -// Copyright (c) 2014 Alexandre Cesaro - -import ( - "bytes" - "encoding/base64" - "io" - "mime" - "mime/multipart" - "mime/quotedprintable" - "os" - "path/filepath" - "strings" - "time" -) - -var newQPWriter = quotedprintable.NewWriter - -type mimeEncoder struct { - mime.WordEncoder -} - -var ( - bEncoding = mimeEncoder{mime.BEncoding} - qEncoding = mimeEncoder{mime.QEncoding} - lastIndexByte = strings.LastIndexByte -) - -// Message represents an email. -type Message struct { - header header - parts []*part - attachments []*file - embedded []*file - charset string - encoding Encoding - hEncoder mimeEncoder - buf bytes.Buffer -} - -type header map[string][]string - -type part struct { - contentType string - copier func(io.Writer) error - encoding Encoding -} - -// NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding -// by default. -func NewMessage(settings ...MessageSetting) *Message { - m := &Message{ - header: make(header), - charset: "UTF-8", - encoding: QuotedPrintable, - } - - m.applySettings(settings) - - if m.encoding == Base64 { - m.hEncoder = bEncoding - } else { - m.hEncoder = qEncoding - } - - return m -} - -// Reset resets the message so it can be reused. The message keeps its previous -// settings so it is in the same state that after a call to NewMessage. -func (m *Message) Reset() { - for k := range m.header { - delete(m.header, k) - } - m.parts = nil - m.attachments = nil - m.embedded = nil -} - -func (m *Message) applySettings(settings []MessageSetting) { - for _, s := range settings { - s(m) - } -} - -// A MessageSetting can be used as an argument in NewMessage to configure an -// email. -type MessageSetting func(m *Message) - -// SetCharset is a message setting to set the charset of the email. -func SetCharset(charset string) MessageSetting { - return func(m *Message) { - m.charset = charset - } -} - -// SetEncoding is a message setting to set the encoding of the email. -func SetEncoding(enc Encoding) MessageSetting { - return func(m *Message) { - m.encoding = enc - } -} - -// Encoding represents a MIME encoding scheme like quoted-printable or base64. -type Encoding string - -const ( - // QuotedPrintable represents the quoted-printable encoding as defined in - // RFC 2045. - QuotedPrintable Encoding = "quoted-printable" - // Base64 represents the base64 encoding as defined in RFC 2045. - Base64 Encoding = "base64" - // Unencoded can be used to avoid encoding the body of an email. The headers - // will still be encoded using quoted-printable encoding. - Unencoded Encoding = "8bit" -) - -// SetHeader sets a value to the given header field. -func (m *Message) SetHeader(field string, value ...string) { - m.encodeHeader(value) - m.header[field] = value -} - -func (m *Message) encodeHeader(values []string) { - for i := range values { - values[i] = m.encodeString(values[i]) - } -} - -func (m *Message) encodeString(value string) string { - return m.hEncoder.Encode(m.charset, value) -} - -// SetHeaders sets the message headers. -func (m *Message) SetHeaders(h map[string][]string) { - for k, v := range h { - m.SetHeader(k, v...) - } -} - -// SetAddressHeader sets an address to the given header field. -func (m *Message) SetAddressHeader(field, address, name string) { - m.header[field] = []string{m.FormatAddress(address, name)} -} - -// FormatAddress formats an address and a name as a valid RFC 5322 address. -func (m *Message) FormatAddress(address, name string) string { - if name == "" { - return address - } - - enc := m.encodeString(name) - if enc == name { - m.buf.WriteByte('"') - for i := 0; i < len(name); i++ { - b := name[i] - if b == '\\' || b == '"' { - m.buf.WriteByte('\\') - } - m.buf.WriteByte(b) - } - m.buf.WriteByte('"') - } else if hasSpecials(name) { - m.buf.WriteString(bEncoding.Encode(m.charset, name)) - } else { - m.buf.WriteString(enc) - } - m.buf.WriteString(" <") - m.buf.WriteString(address) - m.buf.WriteByte('>') - - addr := m.buf.String() - m.buf.Reset() - return addr -} - -func hasSpecials(text string) bool { - for i := 0; i < len(text); i++ { - switch c := text[i]; c { - case '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"': - return true - } - } - - return false -} - -// SetDateHeader sets a date to the given header field. -func (m *Message) SetDateHeader(field string, date time.Time) { - m.header[field] = []string{m.FormatDate(date)} -} - -// FormatDate formats a date as a valid RFC 5322 date. -func (m *Message) FormatDate(date time.Time) string { - return date.Format(time.RFC1123Z) -} - -// GetHeader gets a header field. -func (m *Message) GetHeader(field string) []string { - return m.header[field] -} - -// SetBody sets the body of the message. It replaces any content previously set -// by SetBody, AddAlternative or AddAlternativeWriter. -func (m *Message) SetBody(contentType, body string, settings ...PartSetting) { - m.parts = []*part{m.newPart(contentType, newCopier(body), settings)} -} - -// AddAlternative adds an alternative part to the message. -// -// It is commonly used to send HTML emails that default to the plain text -// version for backward compatibility. AddAlternative appends the new part to -// the end of the message. So the plain text part should be added before the -// HTML part. See http://en.wikipedia.org/wiki/MIME#Alternative -func (m *Message) AddAlternative(contentType, body string, settings ...PartSetting) { - m.AddAlternativeWriter(contentType, newCopier(body), settings...) -} - -func newCopier(s string) func(io.Writer) error { - return func(w io.Writer) error { - _, err := io.WriteString(w, s) - return err - } -} - -// AddAlternativeWriter adds an alternative part to the message. It can be -// useful with the text/template or html/template packages. -func (m *Message) AddAlternativeWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) { - m.parts = append(m.parts, m.newPart(contentType, f, settings)) -} - -func (m *Message) newPart(contentType string, f func(io.Writer) error, settings []PartSetting) *part { - p := &part{ - contentType: contentType, - copier: f, - encoding: m.encoding, - } - - for _, s := range settings { - s(p) - } - - return p -} - -// A PartSetting can be used as an argument in Message.SetBody, -// Message.AddAlternative or Message.AddAlternativeWriter to configure the part -// added to a message. -type PartSetting func(*part) - -// SetPartEncoding sets the encoding of the part added to the message. By -// default, parts use the same encoding than the message. -func SetPartEncoding(e Encoding) PartSetting { - return PartSetting(func(p *part) { - p.encoding = e - }) -} - -type file struct { - Name string - Header map[string][]string - CopyFunc func(w io.Writer) error -} - -func (f *file) setHeader(field, value string) { - f.Header[field] = []string{value} -} - -// A FileSetting can be used as an argument in Message.Attach or Message.Embed. -type FileSetting func(*file) - -// SetHeader is a file setting to set the MIME header of the message part that -// contains the file content. -// -// Mandatory headers are automatically added if they are not set when sending -// the email. -func SetHeader(h map[string][]string) FileSetting { - return func(f *file) { - for k, v := range h { - f.Header[k] = v - } - } -} - -// Rename is a file setting to set the name of the attachment if the name is -// different than the filename on disk. -func Rename(name string) FileSetting { - return func(f *file) { - f.Name = name - } -} - -// SetCopyFunc is a file setting to replace the function that runs when the -// message is sent. It should copy the content of the file to the io.Writer. -// -// The default copy function opens the file with the given filename, and copy -// its content to the io.Writer. -func SetCopyFunc(f func(io.Writer) error) FileSetting { - return func(fi *file) { - fi.CopyFunc = f - } -} - -func (m *Message) appendFile(list []*file, name string, settings []FileSetting) []*file { - f := &file{ - Name: filepath.Base(name), - Header: make(map[string][]string), - CopyFunc: func(w io.Writer) error { - h, err := os.Open(name) - if err != nil { - return err - } - if _, err := io.Copy(w, h); err != nil { - h.Close() - return err - } - return h.Close() - }, - } - - for _, s := range settings { - s(f) - } - - if list == nil { - return []*file{f} - } - - return append(list, f) -} - -// Attach attaches the files to the email. -func (m *Message) Attach(filename string, settings ...FileSetting) { - m.attachments = m.appendFile(m.attachments, filename, settings) -} - -// Embed embeds the images to the email. -func (m *Message) Embed(filename string, settings ...FileSetting) { - m.embedded = m.appendFile(m.embedded, filename, settings) -} - -// WriteTo implements io.WriterTo. It dumps the whole message into w. -func (m *Message) WriteTo(w io.Writer) (int64, error) { - mw := &messageWriter{w: w} - mw.writeMessage(m) - return mw.n, mw.err -} - -func (w *messageWriter) writeMessage(m *Message) { - if _, ok := m.header["Mime-Version"]; !ok { - w.writeString("Mime-Version: 1.0\r\n") - } - if _, ok := m.header["Date"]; !ok { - w.writeHeader("Date", m.FormatDate(time.Now())) - } - w.writeHeaders(m.header) - - if m.hasMixedPart() { - w.openMultipart("mixed") - } - - if m.hasRelatedPart() { - w.openMultipart("related") - } - - if m.hasAlternativePart() { - w.openMultipart("alternative") - } - for _, part := range m.parts { - w.writePart(part, m.charset) - } - if m.hasAlternativePart() { - w.closeMultipart() - } - - w.addFiles(m.embedded, false) - if m.hasRelatedPart() { - w.closeMultipart() - } - - w.addFiles(m.attachments, true) - if m.hasMixedPart() { - w.closeMultipart() - } -} - -func (m *Message) hasMixedPart() bool { - return (len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1 -} - -func (m *Message) hasRelatedPart() bool { - return (len(m.parts) > 0 && len(m.embedded) > 0) || len(m.embedded) > 1 -} - -func (m *Message) hasAlternativePart() bool { - return len(m.parts) > 1 -} - -type messageWriter struct { - w io.Writer - n int64 - writers [3]*multipart.Writer - partWriter io.Writer - depth uint8 - err error -} - -func (w *messageWriter) openMultipart(mimeType string) { - mw := multipart.NewWriter(w) - contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary() - w.writers[w.depth] = mw - - if w.depth == 0 { - w.writeHeader("Content-Type", contentType) - w.writeString("\r\n") - } else { - w.createPart(map[string][]string{ - "Content-Type": {contentType}, - }) - } - w.depth++ -} - -func (w *messageWriter) createPart(h map[string][]string) { - w.partWriter, w.err = w.writers[w.depth-1].CreatePart(h) -} - -func (w *messageWriter) closeMultipart() { - if w.depth > 0 { - w.writers[w.depth-1].Close() - w.depth-- - } -} - -func (w *messageWriter) writePart(p *part, charset string) { - w.writeHeaders(map[string][]string{ - "Content-Type": {p.contentType + "; charset=" + charset}, - "Content-Transfer-Encoding": {string(p.encoding)}, - }) - w.writeBody(p.copier, p.encoding) -} - -func (w *messageWriter) addFiles(files []*file, isAttachment bool) { - for _, f := range files { - if _, ok := f.Header["Content-Type"]; !ok { - mediaType := mime.TypeByExtension(filepath.Ext(f.Name)) - if mediaType == "" { - mediaType = "application/octet-stream" - } - f.setHeader("Content-Type", mediaType+`; name="`+f.Name+`"`) - } - - if _, ok := f.Header["Content-Transfer-Encoding"]; !ok { - f.setHeader("Content-Transfer-Encoding", string(Base64)) - } - - if _, ok := f.Header["Content-Disposition"]; !ok { - var disp string - if isAttachment { - disp = "attachment" - } else { - disp = "inline" - } - f.setHeader("Content-Disposition", disp+`; filename="`+f.Name+`"`) - } - - if !isAttachment { - if _, ok := f.Header["Content-ID"]; !ok { - f.setHeader("Content-ID", "<"+f.Name+">") - } - } - w.writeHeaders(f.Header) - w.writeBody(f.CopyFunc, Base64) - } -} - -func (w *messageWriter) Write(p []byte) (int, error) { - if w.err != nil { - return 0, w.err - } - - var n int - n, w.err = w.w.Write(p) - w.n += int64(n) - return n, w.err -} - -func (w *messageWriter) writeString(s string) { - n, _ := io.WriteString(w.w, s) - w.n += int64(n) -} - -func (w *messageWriter) writeHeader(k string, v ...string) { - w.writeString(k) - if len(v) == 0 { - w.writeString(":\r\n") - return - } - w.writeString(": ") - - // Max header line length is 78 characters in RFC 5322 and 76 characters - // in RFC 2047. So for the sake of simplicity we use the 76 characters - // limit. - charsLeft := 76 - len(k) - len(": ") - - for i, s := range v { - // If the line is already too long, insert a newline right away. - if charsLeft < 1 { - if i == 0 { - w.writeString("\r\n ") - } else { - w.writeString(",\r\n ") - } - charsLeft = 75 - } else if i != 0 { - w.writeString(", ") - charsLeft -= 2 - } - - // While the header content is too long, fold it by inserting a newline. - for len(s) > charsLeft { - s = w.writeLine(s, charsLeft) - charsLeft = 75 - } - w.writeString(s) - if i := lastIndexByte(s, '\n'); i != -1 { - charsLeft = 75 - (len(s) - i - 1) - } else { - charsLeft -= len(s) - } - } - w.writeString("\r\n") -} - -func (w *messageWriter) writeLine(s string, charsLeft int) string { - // If there is already a newline before the limit. Write the line. - if i := strings.IndexByte(s, '\n'); i != -1 && i < charsLeft { - w.writeString(s[:i+1]) - return s[i+1:] - } - - for i := charsLeft - 1; i >= 0; i-- { - if s[i] == ' ' { - w.writeString(s[:i]) - w.writeString("\r\n ") - return s[i+1:] - } - } - - // We could not insert a newline cleanly so look for a space or a newline - // even if it is after the limit. - for i := 75; i < len(s); i++ { - if s[i] == ' ' { - w.writeString(s[:i]) - w.writeString("\r\n ") - return s[i+1:] - } - if s[i] == '\n' { - w.writeString(s[:i+1]) - return s[i+1:] - } - } - - // Too bad, no space or newline in the whole string. Just write everything. - w.writeString(s) - return "" -} - -func (w *messageWriter) writeHeaders(h map[string][]string) { - if w.depth == 0 { - for k, v := range h { - if k != "Bcc" { - w.writeHeader(k, v...) - } - } - } else { - w.createPart(h) - } -} - -func (w *messageWriter) writeBody(f func(io.Writer) error, enc Encoding) { - var subWriter io.Writer - if w.depth == 0 { - w.writeString("\r\n") - subWriter = w.w - } else { - subWriter = w.partWriter - } - - if enc == Base64 { - wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter)) - w.err = f(wc) - wc.Close() - } else if enc == Unencoded { - w.err = f(subWriter) - } else { - wc := newQPWriter(subWriter) - w.err = f(wc) - wc.Close() - } -} - -// As required by RFC 2045, 6.7. (page 21) for quoted-printable, and -// RFC 2045, 6.8. (page 25) for base64. -const maxLineLen = 76 - -// base64LineWriter limits text encoded in base64 to 76 characters per line -type base64LineWriter struct { - w io.Writer - lineLen int -} - -func newBase64LineWriter(w io.Writer) *base64LineWriter { - return &base64LineWriter{w: w} -} - -func (w *base64LineWriter) Write(p []byte) (int, error) { - n := 0 - for len(p)+w.lineLen > maxLineLen { - w.w.Write(p[:maxLineLen-w.lineLen]) - w.w.Write([]byte("\r\n")) - p = p[maxLineLen-w.lineLen:] - n += maxLineLen - w.lineLen - w.lineLen = 0 - } - - w.w.Write(p) - w.lineLen += len(p) - - return n + len(p), nil -} diff --git a/internal/utils/stringutil/addr.go b/internal/utils/stringutil/addr.go new file mode 100644 index 0000000..2121492 --- /dev/null +++ b/internal/utils/stringutil/addr.go @@ -0,0 +1,21 @@ +package stringutil + +import ( + "bytes" + "net/mail" +) + +// JoinAddress formats a slice of Address structs such that they can be used in a To or Cc header. +func JoinAddress(addrs []mail.Address) string { + if len(addrs) == 0 { + return "" + } + buf := &bytes.Buffer{} + for i, a := range addrs { + if i > 0 { + _, _ = buf.WriteString(", ") + } + _, _ = buf.WriteString(a.String()) + } + return buf.String() +} diff --git a/internal/utils/stringutil/split.go b/internal/utils/stringutil/split.go new file mode 100644 index 0000000..b0cf335 --- /dev/null +++ b/internal/utils/stringutil/split.go @@ -0,0 +1,37 @@ +package stringutil + +const escape = '\\' + +// SplitQuoted splits a string, ignoring separators present inside of quoted runs. Separators +// cannot be escaped outside of quoted runs, the escaping will be ignored. +// +// Quotes are preserved in result, but the separators are removed. +func SplitQuoted(s string, sep rune, quote rune) []string { + a := make([]string, 0, 8) + quoted := false + escaped := false + p := 0 + for i, c := range s { + if c == escape { + // Escape can escape itself. + escaped = !escaped + continue + } + if c == quote { + quoted = !quoted + continue + } + escaped = false + if !quoted && c == sep { + a = append(a, s[p:i]) + p = i + 1 + } + } + + if quoted && quote != 0 { + // s contained an unterminated quoted-run, re-split without quoting. + return SplitQuoted(s, sep, rune(0)) + } + + return append(a, s[p:]) +} diff --git a/internal/utils/stringutil/uuid.go b/internal/utils/stringutil/uuid.go new file mode 100644 index 0000000..04649c1 --- /dev/null +++ b/internal/utils/stringutil/uuid.go @@ -0,0 +1,24 @@ +package stringutil + +import ( + "fmt" + "math/rand" + "sync" + "time" +) + +var uuidRand = rand.New(rand.NewSource(time.Now().UnixNano())) +var uuidMutex = &sync.Mutex{} + +// UUID generates a random UUID according to RFC 4122. +func UUID() string { + uuid := make([]byte, 16) + uuidMutex.Lock() + _, _ = uuidRand.Read(uuid) + uuidMutex.Unlock() + // variant bits; see section 4.1.1 + uuid[8] = uuid[8]&^0xc0 | 0x80 + // version 4 (pseudo-random); see section 4.1.3 + uuid[6] = uuid[6]&^0xf0 | 0x40 + return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]) +} diff --git a/internal/utils/stringutil/wrap.go b/internal/utils/stringutil/wrap.go new file mode 100644 index 0000000..eb071ae --- /dev/null +++ b/internal/utils/stringutil/wrap.go @@ -0,0 +1,36 @@ +package stringutil + +// Wrap builds a byte slice from strs, wrapping on word boundaries before max chars +func Wrap(max int, strs ...string) []byte { + input := make([]byte, 0) + output := make([]byte, 0) + for _, s := range strs { + input = append(input, []byte(s)...) + } + if len(input) < max { + // Doesn't need to be wrapped + return input + } + ls := -1 // Last seen space index + lw := -1 // Last written byte index + ll := 0 // Length of current line + for i := 0; i < len(input); i++ { + ll++ + switch input[i] { + case ' ', '\t': + ls = i + } + if ll >= max { + if ls >= 0 { + output = append(output, input[lw+1:ls]...) + output = append(output, '\r', '\n', ' ') + lw = ls // Jump over the space we broke on + ll = 1 // Count leading space above + // Rewind + i = lw + 1 + ls = -1 + } + } + } + return append(output, input[lw+1:]...) +}