Fix several panics on invalid input
This commit is contained in:
commit
66f94a07ae
12 changed files with 2355 additions and 0 deletions
3
.hgignore
Normal file
3
.hgignore
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
syntax: glob
|
||||||
|
|
||||||
|
*.orig
|
20
LICENSE
Normal file
20
LICENSE
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
Copyright (c) 2014 Christian Joergensen (christian@technobabble.dk)
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
19
README.md
Normal file
19
README.md
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
Go smtpd [](https://godoc.org/bitbucket.org/chrj/smtpd)
|
||||||
|
========
|
||||||
|
|
||||||
|
Package smtpd implements an SMTP server in golang.
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
* STARTTLS (using `crypto/tls`)
|
||||||
|
* Authentication (PLAIN/LOGIN, only after STARTTLS)
|
||||||
|
* XCLIENT (for running behind a proxy)
|
||||||
|
* Connection, HELO, sender and recipient checks for rejecting e-mails using callbacks
|
||||||
|
* Configurable limits for: connection count, message size and recipient count
|
||||||
|
* Hands incoming e-mail off to a configured callback function
|
||||||
|
|
||||||
|
Feedback
|
||||||
|
--------
|
||||||
|
|
||||||
|
If you end up using this package or have any feedback, I'd very much like to hear about it. You can reach me by [email](mailto:christian@technobabble.dk).
|
19
address.go
Normal file
19
address.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package smtpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseAddress(src string) (string, error) {
|
||||||
|
|
||||||
|
if len(src) == 0 || src[0] != '<' || src[len(src)-1] != '>' {
|
||||||
|
return "", fmt.Errorf("Ill-formatted e-mail address: %s", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Count(src, "@") > 1 {
|
||||||
|
return "", fmt.Errorf("Ill-formatted e-mail address: %s", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
return src[1 : len(src)-1], nil
|
||||||
|
}
|
54
envelope.go
Normal file
54
envelope.go
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
package smtpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Envelope holds a message
|
||||||
|
type Envelope struct {
|
||||||
|
Sender string
|
||||||
|
Recipients []string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddReceivedLine prepends a Received header to the Data
|
||||||
|
func (env *Envelope) AddReceivedLine(peer Peer) {
|
||||||
|
|
||||||
|
tlsDetails := ""
|
||||||
|
|
||||||
|
tlsVersions := map[uint16]string{
|
||||||
|
tls.VersionSSL30: "SSL3.0",
|
||||||
|
tls.VersionTLS10: "TLS1.0",
|
||||||
|
tls.VersionTLS11: "TLS1.1",
|
||||||
|
tls.VersionTLS12: "TLS1.2",
|
||||||
|
}
|
||||||
|
|
||||||
|
if peer.TLS != nil {
|
||||||
|
tlsDetails = fmt.Sprintf(
|
||||||
|
"\r\n\t(version=%s cipher=0x%x);",
|
||||||
|
tlsVersions[peer.TLS.Version],
|
||||||
|
peer.TLS.CipherSuite,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := wrap([]byte(fmt.Sprintf(
|
||||||
|
"Received: from %s [%s] by %s with %s;%s\r\n\t%s\r\n",
|
||||||
|
peer.HeloName,
|
||||||
|
strings.Split(peer.Addr.String(), ":")[0],
|
||||||
|
peer.ServerName,
|
||||||
|
peer.Protocol,
|
||||||
|
tlsDetails,
|
||||||
|
time.Now().Format("Mon Jan 2 15:04:05 -0700 2006"),
|
||||||
|
)))
|
||||||
|
|
||||||
|
env.Data = append(env.Data, line...)
|
||||||
|
|
||||||
|
// Move the new Received line up front
|
||||||
|
|
||||||
|
copy(env.Data[len(line):], env.Data[0:len(env.Data)-len(line)])
|
||||||
|
copy(env.Data, line)
|
||||||
|
|
||||||
|
}
|
46
example_test.go
Normal file
46
example_test.go
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
package smtpd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bitbucket.org/chrj/smtpd"
|
||||||
|
"errors"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleServer() {
|
||||||
|
var server *smtpd.Server
|
||||||
|
|
||||||
|
// No-op server. Accepts and discards
|
||||||
|
server = &smtpd.Server{}
|
||||||
|
server.ListenAndServe("127.0.0.1:10025")
|
||||||
|
|
||||||
|
// Relay server. Accepts only from single IP address and forwards using the Gmail smtp
|
||||||
|
server = &smtpd.Server{
|
||||||
|
|
||||||
|
HeloChecker: func(peer smtpd.Peer, name string) error {
|
||||||
|
if !strings.HasPrefix(peer.Addr.String(), "42.42.42.42:") {
|
||||||
|
return errors.New("Denied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
|
||||||
|
Handler: func(peer smtpd.Peer, env smtpd.Envelope) error {
|
||||||
|
|
||||||
|
return smtp.SendMail(
|
||||||
|
"smtp.gmail.com:587",
|
||||||
|
smtp.PlainAuth(
|
||||||
|
"",
|
||||||
|
"username@gmail.com",
|
||||||
|
"password",
|
||||||
|
"smtp.gmail.com",
|
||||||
|
),
|
||||||
|
env.Sender,
|
||||||
|
env.Recipients,
|
||||||
|
env.Data,
|
||||||
|
)
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
server.ListenAndServe("127.0.0.1:10025")
|
||||||
|
}
|
80
examples/dkim-proxy/main.go
Normal file
80
examples/dkim-proxy/main.go
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
// Command dkim-proxy implements a simple SMTP proxy that DKIM signs incoming e-mail and relays to another SMTP server for delivery
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"flag"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/smtp"
|
||||||
|
|
||||||
|
"bitbucket.org/chrj/smtpd"
|
||||||
|
"github.com/eaigner/dkim"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
welcomeMsg = flag.String("welcome", "DKIM-proxy ESMTP ready.", "Welcome message for SMTP session")
|
||||||
|
inAddr = flag.String("inaddr", "localhost:10025", "Address to listen for incoming SMTP on")
|
||||||
|
outAddr = flag.String("outaddr", "localhost:25", "Address to deliver outgoing SMTP on")
|
||||||
|
privKeyFile = flag.String("key", "", "Private key file.")
|
||||||
|
dkimS = flag.String("s", "default", "DKIM selector")
|
||||||
|
dkimD = flag.String("d", "", "DKIM domain")
|
||||||
|
|
||||||
|
dkimConf dkim.Conf
|
||||||
|
privKey []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func handler(peer smtpd.Peer, env smtpd.Envelope) error {
|
||||||
|
|
||||||
|
d, err := dkim.New(dkimConf, privKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("DKIM error: %v", err)
|
||||||
|
return smtpd.Error{450, "Internal server error"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dkim package expects \r\n newlines, so replace to that
|
||||||
|
data, err := d.Sign(bytes.Replace(env.Data, []byte("\n"), []byte("\r\n"), -1))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("DKIM signing error: %v", err)
|
||||||
|
return smtpd.Error{450, "Internal server error"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return smtp.SendMail(
|
||||||
|
*outAddr,
|
||||||
|
nil,
|
||||||
|
env.Sender,
|
||||||
|
env.Recipients,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
dkimConf, err = dkim.NewConf(*dkimD, *dkimS)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("DKIM configuration error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privKey, err = ioutil.ReadFile(*privKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Couldn't read private key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dkim.New(dkimConf, privKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("DKIM error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &smtpd.Server{
|
||||||
|
WelcomeMessage: *welcomeMsg,
|
||||||
|
Handler: handler,
|
||||||
|
}
|
||||||
|
|
||||||
|
server.ListenAndServe(*inAddr)
|
||||||
|
|
||||||
|
}
|
582
protocol.go
Normal file
582
protocol.go
Normal file
|
@ -0,0 +1,582 @@
|
||||||
|
package smtpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net"
|
||||||
|
"net/textproto"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type command struct {
|
||||||
|
line string
|
||||||
|
action string
|
||||||
|
fields []string
|
||||||
|
params []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLine(line string) (cmd command) {
|
||||||
|
|
||||||
|
cmd.line = line
|
||||||
|
cmd.fields = strings.Fields(line)
|
||||||
|
|
||||||
|
if len(cmd.fields) > 0 {
|
||||||
|
cmd.action = strings.ToUpper(cmd.fields[0])
|
||||||
|
if len(cmd.fields) > 1 {
|
||||||
|
cmd.params = strings.Split(cmd.fields[1], ":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handle(line string) {
|
||||||
|
|
||||||
|
cmd := parseLine(line)
|
||||||
|
|
||||||
|
// Commands are dispatched to the appropriate handler functions.
|
||||||
|
// If a network error occurs during handling, the handler should
|
||||||
|
// just return and let the error be handled on the next read.
|
||||||
|
|
||||||
|
switch cmd.action {
|
||||||
|
|
||||||
|
case "HELO":
|
||||||
|
session.handleHELO(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "EHLO":
|
||||||
|
session.handleEHLO(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "MAIL":
|
||||||
|
session.handleMAIL(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "RCPT":
|
||||||
|
session.handleRCPT(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "STARTTLS":
|
||||||
|
session.handleSTARTTLS(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "DATA":
|
||||||
|
session.handleDATA(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "RSET":
|
||||||
|
session.handleRSET(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "NOOP":
|
||||||
|
session.handleNOOP(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "QUIT":
|
||||||
|
session.handleQUIT(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "AUTH":
|
||||||
|
session.handleAUTH(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
case "XCLIENT":
|
||||||
|
session.handleXCLIENT(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
session.reply(502, "Unsupported command.")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleHELO(cmd command) {
|
||||||
|
|
||||||
|
if len(cmd.fields) < 2 {
|
||||||
|
session.reply(502, "Missing parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.peer.HeloName != "" {
|
||||||
|
// Reset envelope in case of duplicate HELO
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.HeloChecker != nil {
|
||||||
|
err := session.server.HeloChecker(session.peer, cmd.fields[1])
|
||||||
|
if err != nil {
|
||||||
|
session.error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.peer.HeloName = cmd.fields[1]
|
||||||
|
session.peer.Protocol = SMTP
|
||||||
|
session.reply(250, "Go ahead")
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleEHLO(cmd command) {
|
||||||
|
|
||||||
|
if len(cmd.fields) < 2 {
|
||||||
|
session.reply(502, "Missing parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.peer.HeloName != "" {
|
||||||
|
// Reset envelope in case of duplicate EHLO
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.HeloChecker != nil {
|
||||||
|
err := session.server.HeloChecker(session.peer, cmd.fields[1])
|
||||||
|
if err != nil {
|
||||||
|
session.error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.peer.HeloName = cmd.fields[1]
|
||||||
|
session.peer.Protocol = ESMTP
|
||||||
|
|
||||||
|
fmt.Fprintf(session.writer, "250-%s\r\n", session.server.Hostname)
|
||||||
|
|
||||||
|
extensions := session.extensions()
|
||||||
|
|
||||||
|
if len(extensions) > 1 {
|
||||||
|
for _, ext := range extensions[:len(extensions)-1] {
|
||||||
|
fmt.Fprintf(session.writer, "250-%s\r\n", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.reply(250, extensions[len(extensions)-1])
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleMAIL(cmd command) {
|
||||||
|
if len(cmd.params) != 2 || strings.ToUpper(cmd.params[0]) != "FROM" {
|
||||||
|
session.reply(502, "Syntax error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.peer.HeloName == "" {
|
||||||
|
session.reply(502, "Please introduce yourself first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.tls && session.server.ForceTLS {
|
||||||
|
session.reply(502, "Please turn on TLS by issuing a STARTTLS command")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.envelope != nil {
|
||||||
|
session.reply(502, "Duplicate MAIL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, err := parseAddress(cmd.params[1])
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
session.reply(502, "Ill-formatted e-mail address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.SenderChecker != nil {
|
||||||
|
err = session.server.SenderChecker(session.peer, addr)
|
||||||
|
if err != nil {
|
||||||
|
session.error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.envelope = &Envelope{
|
||||||
|
Sender: addr,
|
||||||
|
}
|
||||||
|
|
||||||
|
session.reply(250, "Go ahead")
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleRCPT(cmd command) {
|
||||||
|
if len(cmd.params) != 2 || strings.ToUpper(cmd.params[0]) != "TO" {
|
||||||
|
session.reply(502, "Syntax error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.envelope == nil {
|
||||||
|
session.reply(502, "Missing MAIL FROM command.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(session.envelope.Recipients) >= session.server.MaxRecipients {
|
||||||
|
session.reply(452, "Too many recipients")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, err := parseAddress(cmd.params[1])
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
session.reply(502, "Ill-formatted e-mail address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.RecipientChecker != nil {
|
||||||
|
err = session.server.RecipientChecker(session.peer, addr)
|
||||||
|
if err != nil {
|
||||||
|
session.error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.envelope.Recipients = append(session.envelope.Recipients, addr)
|
||||||
|
|
||||||
|
session.reply(250, "Go ahead")
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleSTARTTLS(cmd command) {
|
||||||
|
|
||||||
|
if session.tls {
|
||||||
|
session.reply(502, "Already running in TLS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.TLSConfig == nil {
|
||||||
|
session.reply(502, "TLS not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConn := tls.Server(session.conn, session.server.TLSConfig)
|
||||||
|
session.reply(220, "Go ahead")
|
||||||
|
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
session.reply(550, "Handshake error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset envelope as a new EHLO/HELO is required after STARTTLS
|
||||||
|
session.reset()
|
||||||
|
|
||||||
|
// Reset deadlines on the underlying connection before I replace it
|
||||||
|
// with a TLS connection
|
||||||
|
session.conn.SetDeadline(time.Time{})
|
||||||
|
|
||||||
|
// Replace connection with a TLS connection
|
||||||
|
session.conn = tlsConn
|
||||||
|
session.reader = bufio.NewReader(tlsConn)
|
||||||
|
session.writer = bufio.NewWriter(tlsConn)
|
||||||
|
session.scanner = bufio.NewScanner(session.reader)
|
||||||
|
session.tls = true
|
||||||
|
|
||||||
|
// Save connection state on peer
|
||||||
|
state := tlsConn.ConnectionState()
|
||||||
|
session.peer.TLS = &state
|
||||||
|
|
||||||
|
// Flush the connection to set new timeout deadlines
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleDATA(cmd command) {
|
||||||
|
|
||||||
|
if session.envelope == nil || len(session.envelope.Recipients) == 0 {
|
||||||
|
session.reply(502, "Missing RCPT TO command.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.reply(354, "Go ahead. End your data with <CR><LF>.<CR><LF>")
|
||||||
|
session.conn.SetDeadline(time.Now().Add(session.server.DataTimeout))
|
||||||
|
|
||||||
|
data := &bytes.Buffer{}
|
||||||
|
reader := textproto.NewReader(session.reader).DotReader()
|
||||||
|
|
||||||
|
_, err := io.CopyN(data, reader, int64(session.server.MaxMessageSize))
|
||||||
|
|
||||||
|
if err == io.EOF {
|
||||||
|
|
||||||
|
// EOF was reached before MaxMessageSize
|
||||||
|
// Accept and deliver message
|
||||||
|
|
||||||
|
session.envelope.Data = data.Bytes()
|
||||||
|
|
||||||
|
if err := session.deliver(); err != nil {
|
||||||
|
session.error(err)
|
||||||
|
} else {
|
||||||
|
session.reply(250, "Thank you.")
|
||||||
|
}
|
||||||
|
|
||||||
|
session.reset()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Network error, ignore
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard the rest and report an error.
|
||||||
|
_, err = io.Copy(ioutil.Discard, reader)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Network error, ignore
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.reply(552, fmt.Sprintf(
|
||||||
|
"Message exceeded max message size of %d bytes",
|
||||||
|
session.server.MaxMessageSize,
|
||||||
|
))
|
||||||
|
|
||||||
|
session.reset()
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleRSET(cmd command) {
|
||||||
|
session.reset()
|
||||||
|
session.reply(250, "Go ahead")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleNOOP(cmd command) {
|
||||||
|
session.reply(250, "Go ahead")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleQUIT(cmd command) {
|
||||||
|
session.reply(221, "OK, bye")
|
||||||
|
session.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleAUTH(cmd command) {
|
||||||
|
if len(cmd.fields) < 2 {
|
||||||
|
session.reply(502, "Invalid syntax.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.Authenticator == nil {
|
||||||
|
session.reply(502, "AUTH not supported.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.peer.HeloName == "" {
|
||||||
|
session.reply(502, "Please introduce yourself first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.tls {
|
||||||
|
session.reply(502, "Cannot AUTH in plain text mode. Use STARTTLS.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mechanism := strings.ToUpper(cmd.fields[1])
|
||||||
|
|
||||||
|
username := ""
|
||||||
|
password := ""
|
||||||
|
|
||||||
|
switch mechanism {
|
||||||
|
|
||||||
|
case "PLAIN":
|
||||||
|
|
||||||
|
auth := ""
|
||||||
|
|
||||||
|
if len(cmd.fields) < 3 {
|
||||||
|
session.reply(334, "Give me your credentials")
|
||||||
|
if !session.scanner.Scan() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth = session.scanner.Text()
|
||||||
|
} else {
|
||||||
|
auth = cmd.fields[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(auth)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
session.reply(502, "Couldn't decode your credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := bytes.Split(data, []byte{0})
|
||||||
|
|
||||||
|
if len(parts) != 3 {
|
||||||
|
session.reply(502, "Couldn't decode your credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username = string(parts[1])
|
||||||
|
password = string(parts[2])
|
||||||
|
|
||||||
|
case "LOGIN":
|
||||||
|
|
||||||
|
session.reply(334, "VXNlcm5hbWU6")
|
||||||
|
|
||||||
|
if !session.scanner.Scan() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
byteUsername, err := base64.StdEncoding.DecodeString(session.scanner.Text())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
session.reply(502, "Couldn't decode your credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.reply(334, "UGFzc3dvcmQ6")
|
||||||
|
|
||||||
|
if !session.scanner.Scan() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bytePassword, err := base64.StdEncoding.DecodeString(session.scanner.Text())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
session.reply(502, "Couldn't decode your credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username = string(byteUsername)
|
||||||
|
password = string(bytePassword)
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
session.reply(502, "Unknown authentication mechanism")
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
err := session.server.Authenticator(session.peer, username, password)
|
||||||
|
if err != nil {
|
||||||
|
session.error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.peer.Username = username
|
||||||
|
session.peer.Password = password
|
||||||
|
|
||||||
|
session.reply(235, "OK, you are now authenticated")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) handleXCLIENT(cmd command) {
|
||||||
|
if len(cmd.fields) < 2 {
|
||||||
|
session.reply(502, "Invalid syntax.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.server.EnableXCLIENT {
|
||||||
|
session.reply(550, "XCLIENT not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
newHeloName = ""
|
||||||
|
newAddr net.IP = nil
|
||||||
|
newTCPPort uint64 = 0
|
||||||
|
newUsername = ""
|
||||||
|
newProto Protocol = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, item := range cmd.fields[1:] {
|
||||||
|
|
||||||
|
parts := strings.Split(item, "=")
|
||||||
|
|
||||||
|
if len(parts) != 2 {
|
||||||
|
session.reply(502, "Couldn't decode the command.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := parts[0]
|
||||||
|
value := parts[1]
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
|
||||||
|
case "NAME":
|
||||||
|
// Unused in smtpd package
|
||||||
|
continue
|
||||||
|
|
||||||
|
case "HELO":
|
||||||
|
newHeloName = value
|
||||||
|
continue
|
||||||
|
|
||||||
|
case "ADDR":
|
||||||
|
newAddr = net.ParseIP(value)
|
||||||
|
continue
|
||||||
|
|
||||||
|
case "PORT":
|
||||||
|
var err error
|
||||||
|
newTCPPort, err = strconv.ParseUint(value, 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
session.reply(502, "Couldn't decode the command.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
case "LOGIN":
|
||||||
|
newUsername = value
|
||||||
|
continue
|
||||||
|
|
||||||
|
case "PROTO":
|
||||||
|
if value == "SMTP" {
|
||||||
|
newProto = SMTP
|
||||||
|
} else if value == "ESMTP" {
|
||||||
|
newProto = ESMTP
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
default:
|
||||||
|
session.reply(502, "Couldn't decode the command.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
tcpAddr, ok := session.peer.Addr.(*net.TCPAddr)
|
||||||
|
if !ok {
|
||||||
|
session.reply(502, "Unsupported network connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if newHeloName != "" {
|
||||||
|
session.peer.HeloName = newHeloName
|
||||||
|
}
|
||||||
|
|
||||||
|
if newAddr != nil {
|
||||||
|
tcpAddr.IP = newAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
if newTCPPort != 0 {
|
||||||
|
tcpAddr.Port = int(newTCPPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newUsername != "" {
|
||||||
|
session.peer.Username = newUsername
|
||||||
|
}
|
||||||
|
|
||||||
|
if newProto != "" {
|
||||||
|
session.peer.Protocol = newProto
|
||||||
|
}
|
||||||
|
|
||||||
|
session.welcome()
|
||||||
|
|
||||||
|
}
|
324
smtpd.go
Normal file
324
smtpd.go
Normal file
|
@ -0,0 +1,324 @@
|
||||||
|
// Package smtpd implements an SMTP server with support for STARTTLS, authentication (PLAIN/LOGIN), XCLIENT and optional restrictions on the different stages of the SMTP session.
|
||||||
|
package smtpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server defines the parameters for running the SMTP server
|
||||||
|
type Server struct {
|
||||||
|
Hostname string // Server hostname. (default: "localhost.localdomain")
|
||||||
|
WelcomeMessage string // Initial server banner. (default: "<hostname> ESMTP ready.")
|
||||||
|
|
||||||
|
ReadTimeout time.Duration // Socket timeout for read operations. (default: 60s)
|
||||||
|
WriteTimeout time.Duration // Socket timeout for write operations. (default: 60s)
|
||||||
|
DataTimeout time.Duration // Socket timeout for DATA command (default: 5m)
|
||||||
|
|
||||||
|
MaxConnections int // Max concurrent connections, use -1 to disable. (default: 100)
|
||||||
|
MaxMessageSize int // Max message size in bytes. (default: 10240000)
|
||||||
|
MaxRecipients int // Max RCPT TO calls for each envelope. (default: 100)
|
||||||
|
|
||||||
|
// New e-mails are handed off to this function.
|
||||||
|
// Can be left empty for a NOOP server.
|
||||||
|
// If an error is returned, it will be reported in the SMTP session.
|
||||||
|
Handler func(peer Peer, env Envelope) error
|
||||||
|
|
||||||
|
// Enable various checks during the SMTP session.
|
||||||
|
// Can be left empty for no restrictions.
|
||||||
|
// If an error is returned, it will be reported in the SMTP session.
|
||||||
|
// Use the Error struct for access to error codes.
|
||||||
|
ConnectionChecker func(peer Peer) error // Called upon new connection.
|
||||||
|
HeloChecker func(peer Peer, name string) error // Called after HELO/EHLO.
|
||||||
|
SenderChecker func(peer Peer, addr string) error // Called after MAIL FROM.
|
||||||
|
RecipientChecker func(peer Peer, addr string) error // Called after each RCPT TO.
|
||||||
|
|
||||||
|
// Enable PLAIN/LOGIN authentication, only available after STARTTLS.
|
||||||
|
// Can be left empty for no authentication support.
|
||||||
|
Authenticator func(peer Peer, username, password string) error
|
||||||
|
|
||||||
|
EnableXCLIENT bool // Enable XCLIENT support (default: false)
|
||||||
|
|
||||||
|
TLSConfig *tls.Config // Enable STARTTLS support.
|
||||||
|
ForceTLS bool // Force STARTTLS usage.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol represents the protocol used in the SMTP session
|
||||||
|
type Protocol string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SMTP Protocol = "SMTP"
|
||||||
|
ESMTP = "ESMTP"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Peer represents the client connecting to the server
|
||||||
|
type Peer struct {
|
||||||
|
HeloName string // Server name used in HELO/EHLO command
|
||||||
|
Username string // Username from authentication, if authenticated
|
||||||
|
Password string // Password from authentication, if authenticated
|
||||||
|
Protocol Protocol // Protocol used, SMTP or ESMTP
|
||||||
|
ServerName string // A copy of Server.Hostname
|
||||||
|
Addr net.Addr // Network address
|
||||||
|
TLS *tls.ConnectionState // TLS Connection details, if on TLS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error represents an Error reported in the SMTP session.
|
||||||
|
type Error struct {
|
||||||
|
Code int // The integer error code
|
||||||
|
Message string // The error message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns a string representation of the SMTP error
|
||||||
|
func (e Error) Error() string { return fmt.Sprintf("%d %s", e.Code, e.Message) }
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
server *Server
|
||||||
|
|
||||||
|
peer Peer
|
||||||
|
envelope *Envelope
|
||||||
|
|
||||||
|
conn net.Conn
|
||||||
|
|
||||||
|
reader *bufio.Reader
|
||||||
|
writer *bufio.Writer
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
|
||||||
|
tls bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) newSession(c net.Conn) (s *session) {
|
||||||
|
|
||||||
|
s = &session{
|
||||||
|
server: srv,
|
||||||
|
conn: c,
|
||||||
|
reader: bufio.NewReader(c),
|
||||||
|
writer: bufio.NewWriter(c),
|
||||||
|
peer: Peer{
|
||||||
|
Addr: c.RemoteAddr(),
|
||||||
|
ServerName: srv.Hostname,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
s.scanner = bufio.NewScanner(s.reader)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe starts the SMTP server and listens on the address provided
|
||||||
|
func (srv *Server) ListenAndServe(addr string) error {
|
||||||
|
|
||||||
|
srv.configureDefaults()
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return srv.Serve(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts the SMTP server and listens on the Listener provided
|
||||||
|
func (srv *Server) Serve(l net.Listener) error {
|
||||||
|
|
||||||
|
srv.configureDefaults()
|
||||||
|
|
||||||
|
defer l.Close()
|
||||||
|
|
||||||
|
var limiter chan struct{}
|
||||||
|
|
||||||
|
if srv.MaxConnections > 0 {
|
||||||
|
limiter = make(chan struct{}, srv.MaxConnections)
|
||||||
|
} else {
|
||||||
|
limiter = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
|
||||||
|
conn, e := l.Accept()
|
||||||
|
if e != nil {
|
||||||
|
if ne, ok := e.(net.Error); ok && ne.Temporary() {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
session := srv.newSession(conn)
|
||||||
|
|
||||||
|
if limiter != nil {
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case limiter <- struct{}{}:
|
||||||
|
session.serve()
|
||||||
|
<-limiter
|
||||||
|
default:
|
||||||
|
session.reject()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
go session.serve()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) configureDefaults() {
|
||||||
|
|
||||||
|
if srv.MaxMessageSize == 0 {
|
||||||
|
srv.MaxMessageSize = 10240000
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.MaxConnections == 0 {
|
||||||
|
srv.MaxConnections = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.MaxRecipients == 0 {
|
||||||
|
srv.MaxRecipients = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.ReadTimeout == 0 {
|
||||||
|
srv.ReadTimeout = time.Second * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.WriteTimeout == 0 {
|
||||||
|
srv.WriteTimeout = time.Second * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.DataTimeout == 0 {
|
||||||
|
srv.DataTimeout = time.Minute * 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.ForceTLS && srv.TLSConfig == nil {
|
||||||
|
log.Fatal("Cannot use ForceTLS with no TLSConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.Hostname == "" {
|
||||||
|
srv.Hostname = "localhost.localdomain"
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.WelcomeMessage == "" {
|
||||||
|
srv.WelcomeMessage = fmt.Sprintf("%s ESMTP ready.", srv.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) serve() {
|
||||||
|
|
||||||
|
defer session.close()
|
||||||
|
|
||||||
|
session.welcome()
|
||||||
|
|
||||||
|
for {
|
||||||
|
|
||||||
|
for session.scanner.Scan() {
|
||||||
|
session.handle(session.scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
err := session.scanner.Err()
|
||||||
|
|
||||||
|
if err == bufio.ErrTooLong {
|
||||||
|
|
||||||
|
session.reply(500, "Line too long")
|
||||||
|
|
||||||
|
// Advance reader to the next newline
|
||||||
|
|
||||||
|
session.reader.ReadString('\n')
|
||||||
|
session.scanner = bufio.NewScanner(session.reader)
|
||||||
|
|
||||||
|
// Reset and have the client start over.
|
||||||
|
|
||||||
|
session.reset()
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) reject() {
|
||||||
|
session.reply(421, "Too busy. Try again later.")
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) reset() {
|
||||||
|
session.envelope = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) welcome() {
|
||||||
|
|
||||||
|
if session.server.ConnectionChecker != nil {
|
||||||
|
err := session.server.ConnectionChecker(session.peer)
|
||||||
|
if err != nil {
|
||||||
|
session.error(err)
|
||||||
|
session.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.reply(220, session.server.WelcomeMessage)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) reply(code int, message string) {
|
||||||
|
fmt.Fprintf(session.writer, "%d %s\r\n", code, message)
|
||||||
|
session.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) flush() {
|
||||||
|
session.conn.SetWriteDeadline(time.Now().Add(session.server.WriteTimeout))
|
||||||
|
session.writer.Flush()
|
||||||
|
session.conn.SetReadDeadline(time.Now().Add(session.server.ReadTimeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) error(err error) {
|
||||||
|
if smtpdError, ok := err.(Error); ok {
|
||||||
|
session.reply(smtpdError.Code, smtpdError.Message)
|
||||||
|
} else {
|
||||||
|
session.reply(502, fmt.Sprintf("%s", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) extensions() []string {
|
||||||
|
|
||||||
|
extensions := []string{
|
||||||
|
fmt.Sprintf("SIZE %d", session.server.MaxMessageSize),
|
||||||
|
"8BITMIME",
|
||||||
|
"PIPELINING",
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.EnableXCLIENT {
|
||||||
|
extensions = append(extensions, "XCLIENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.TLSConfig != nil && !session.tls {
|
||||||
|
extensions = append(extensions, "STARTTLS")
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.server.Authenticator != nil && session.tls {
|
||||||
|
extensions = append(extensions, "AUTH PLAIN LOGIN")
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) deliver() error {
|
||||||
|
if session.server.Handler != nil {
|
||||||
|
return session.server.Handler(session.peer, *session.envelope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session *session) close() {
|
||||||
|
session.writer.Flush()
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
session.conn.Close()
|
||||||
|
}
|
1162
smtpd_test.go
Normal file
1162
smtpd_test.go
Normal file
File diff suppressed because it is too large
Load diff
22
wrap.go
Normal file
22
wrap.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
package smtpd
|
||||||
|
|
||||||
|
// Wrap a byte slice paragraph for use in SMTP header
|
||||||
|
func wrap(sl []byte) []byte {
|
||||||
|
length := 0
|
||||||
|
for i := 0; i < len(sl); i++ {
|
||||||
|
if length > 76 && sl[i] == ' ' {
|
||||||
|
sl = append(sl, 0, 0)
|
||||||
|
copy(sl[i+2:], sl[i:])
|
||||||
|
sl[i] = '\r'
|
||||||
|
sl[i+1] = '\n'
|
||||||
|
sl[i+2] = '\t'
|
||||||
|
i += 2
|
||||||
|
length = 0
|
||||||
|
}
|
||||||
|
if sl[i] == '\n' {
|
||||||
|
length = 0
|
||||||
|
}
|
||||||
|
length++
|
||||||
|
}
|
||||||
|
return sl
|
||||||
|
}
|
24
wrap_test.go
Normal file
24
wrap_test.go
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package smtpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWrap(t *testing.T) {
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"foobar": "foobar",
|
||||||
|
"foobar quux": "foobar quux",
|
||||||
|
"foobar\r\n": "foobar\r\n",
|
||||||
|
"foobar\r\nquux": "foobar\r\nquux",
|
||||||
|
"foobar quux foobar quux foobar quux foobar quux foobar quux foobar quux foobar quux foobar quux": "foobar quux foobar quux foobar quux foobar quux foobar quux foobar quux foobar\r\n\tquux foobar quux",
|
||||||
|
"foobar quux foobar quux foobar quux foobar quux foobar quux foobar\r\n\tquux foobar quux foobar quux": "foobar quux foobar quux foobar quux foobar quux foobar quux foobar\r\n\tquux foobar quux foobar quux",
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range cases {
|
||||||
|
if string(wrap([]byte(k))) != v {
|
||||||
|
t.Fatal("Didn't wrap correctly.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue