mirror of
https://github.com/wneessen/go-mail.git
synced 2024-11-22 05:40:50 +01:00
Some progress was made:
- Implemented proper AUTH LOGIN mechanism - Implemented msgWriter for handling io - Implemented proper mail header formatting/output - Minor refactoring
This commit is contained in:
parent
5aebb12241
commit
06e37755f2
6 changed files with 275 additions and 48 deletions
6
auth.go
6
auth.go
|
@ -15,9 +15,6 @@ const (
|
||||||
|
|
||||||
// SMTPAuthCramMD5 is the "CRAM-MD5" SASL authentication mechanism as described in RFC 4954
|
// SMTPAuthCramMD5 is the "CRAM-MD5" SASL authentication mechanism as described in RFC 4954
|
||||||
SMTPAuthCramMD5 SMTPAuthType = "CRAM-MD5"
|
SMTPAuthCramMD5 SMTPAuthType = "CRAM-MD5"
|
||||||
|
|
||||||
// SMTPAuthDigestMD5 is the "DIGEST-MD5" SASL authentication mechanism as described in RFC 4954
|
|
||||||
SMTPAuthDigestMD5 SMTPAuthType = "DIGEST-MD5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SMTP Auth related static errors
|
// SMTP Auth related static errors
|
||||||
|
@ -30,7 +27,4 @@ var (
|
||||||
|
|
||||||
// ErrCramMD5AuthNotSupported should be used if the target server does not support the "CRAM-MD5" schema
|
// ErrCramMD5AuthNotSupported should be used if the target server does not support the "CRAM-MD5" schema
|
||||||
ErrCramMD5AuthNotSupported = errors.New("server does not support SMTP AUTH type: CRAM-MD5")
|
ErrCramMD5AuthNotSupported = errors.New("server does not support SMTP AUTH type: CRAM-MD5")
|
||||||
|
|
||||||
// ErrDigestMD5AuthNotSupported should be used if the target server does not support the "DIGEST-MD5" schema
|
|
||||||
ErrDigestMD5AuthNotSupported = errors.New("server does not support SMTP AUTH type: DIGEST-MD5")
|
|
||||||
)
|
)
|
||||||
|
|
69
auth/login.go
Normal file
69
auth/login.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// Package auth implements the LOGIN and MD5-DIGEST smtp authentication mechansims
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginAuth struct {
|
||||||
|
username, password string
|
||||||
|
host string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ServerRespUsername represents the "Username:" response by the SMTP server
|
||||||
|
ServerRespUsername = "Username:"
|
||||||
|
|
||||||
|
// ServerRespPassword represents the "Password:" response by the SMTP server
|
||||||
|
ServerRespPassword = "Password:"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginAuth returns an Auth that implements the LOGIN authentication
|
||||||
|
// mechanism as it is used by MS Outlook. The Auth works similar to PLAIN
|
||||||
|
// but instead of sending all in one response, the login is handled within
|
||||||
|
// 3 steps:
|
||||||
|
// - Sending AUTH LOGIN (server responds with "Username:")
|
||||||
|
// - Sending the username (server responds with "Password:")
|
||||||
|
// - Sending the password (server authenticates)
|
||||||
|
//
|
||||||
|
// LoginAuth will only send the credentials if the connection is using TLS
|
||||||
|
// or is connected to localhost. Otherwise authentication will fail with an
|
||||||
|
// error, without sending the credentials.
|
||||||
|
func LoginAuth(username, password, host string) smtp.Auth {
|
||||||
|
return &loginAuth{username, password, host}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocalhost(name string) bool {
|
||||||
|
return name == "localhost" || name == "127.0.0.1" || name == "::1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||||
|
// Must have TLS, or else localhost server.
|
||||||
|
// Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo.
|
||||||
|
// In particular, it doesn't matter if the server advertises LOGIN auth.
|
||||||
|
// That might just be the attacker saying
|
||||||
|
// "it's ok, you can trust me with your password."
|
||||||
|
if !server.TLS && !isLocalhost(server.Name) {
|
||||||
|
return "", nil, errors.New("unencrypted connection")
|
||||||
|
}
|
||||||
|
if server.Name != a.host {
|
||||||
|
return "", nil, errors.New("wrong host name")
|
||||||
|
}
|
||||||
|
return "LOGIN", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||||
|
if more {
|
||||||
|
switch string(fromServer) {
|
||||||
|
case ServerRespUsername:
|
||||||
|
return []byte(a.username), nil
|
||||||
|
case ServerRespPassword:
|
||||||
|
return []byte(a.password), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected server response: %s", string(fromServer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
63
client.go
63
client.go
|
@ -5,6 +5,7 @@ import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/wneessen/go-mail/auth"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"os"
|
"os"
|
||||||
|
@ -275,10 +276,54 @@ func (c *Client) DialWithContext(pc context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send sends out the mail message
|
// Send sends out the mail message
|
||||||
func (c *Client) Send() error {
|
func (c *Client) Send(ml ...*Msg) error {
|
||||||
if err := c.checkConn(); err != nil {
|
if err := c.checkConn(); err != nil {
|
||||||
return fmt.Errorf("failed to send mail: %w", err)
|
return fmt.Errorf("failed to send mail: %w", err)
|
||||||
}
|
}
|
||||||
|
for _, m := range ml {
|
||||||
|
f, err := m.GetSender(false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rl, err := m.GetRecipients()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.sc.Mail(f); err != nil {
|
||||||
|
return fmt.Errorf("sending MAIL FROM command failed: %w", err)
|
||||||
|
}
|
||||||
|
for _, r := range rl {
|
||||||
|
if err := c.sc.Rcpt(r); err != nil {
|
||||||
|
return fmt.Errorf("sending RCPT TO command failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := os.Stderr
|
||||||
|
|
||||||
|
/*
|
||||||
|
w, err := c.sc.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sending DATA command failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
_, err = m.Write(w)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sending mail content failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close DATA writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Reset(); err != nil {
|
||||||
|
return fmt.Errorf("sending RSET command failed: %s", err)
|
||||||
|
}
|
||||||
|
if err := c.checkConn(); err != nil {
|
||||||
|
return fmt.Errorf("failed to check server connection: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -288,7 +333,7 @@ func (c *Client) Close() error {
|
||||||
if err := c.checkConn(); err != nil {
|
if err := c.checkConn(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.sc.Close(); err != nil {
|
if err := c.sc.Quit(); err != nil {
|
||||||
return fmt.Errorf("failed to close SMTP client: %w", err)
|
return fmt.Errorf("failed to close SMTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -309,14 +354,17 @@ func (c *Client) Reset() error {
|
||||||
|
|
||||||
// DialAndSend establishes a connection to the SMTP server with a
|
// DialAndSend establishes a connection to the SMTP server with a
|
||||||
// default context.Background and sends the mail
|
// default context.Background and sends the mail
|
||||||
func (c *Client) DialAndSend() error {
|
func (c *Client) DialAndSend(ml ...*Msg) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := c.DialWithContext(ctx); err != nil {
|
if err := c.DialWithContext(ctx); err != nil {
|
||||||
return fmt.Errorf("dial failed: %w", err)
|
return fmt.Errorf("dial failed: %w", err)
|
||||||
}
|
}
|
||||||
if err := c.Send(); err != nil {
|
if err := c.Send(ml...); err != nil {
|
||||||
return fmt.Errorf("send failed: %w", err)
|
return fmt.Errorf("send failed: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := c.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close connction: %s", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -383,17 +431,12 @@ func (c *Client) auth() error {
|
||||||
if !strings.Contains(sat, string(SMTPAuthLogin)) {
|
if !strings.Contains(sat, string(SMTPAuthLogin)) {
|
||||||
return ErrLoginAuthNotSupported
|
return ErrLoginAuthNotSupported
|
||||||
}
|
}
|
||||||
c.sa = smtp.PlainAuth("", c.user, c.pass, c.host)
|
c.sa = auth.LoginAuth(c.user, c.pass, c.host)
|
||||||
case SMTPAuthCramMD5:
|
case SMTPAuthCramMD5:
|
||||||
if !strings.Contains(sat, string(SMTPAuthCramMD5)) {
|
if !strings.Contains(sat, string(SMTPAuthCramMD5)) {
|
||||||
return ErrCramMD5AuthNotSupported
|
return ErrCramMD5AuthNotSupported
|
||||||
}
|
}
|
||||||
c.sa = smtp.CRAMMD5Auth(c.user, c.pass)
|
c.sa = smtp.CRAMMD5Auth(c.user, c.pass)
|
||||||
case SMTPAuthDigestMD5:
|
|
||||||
if !strings.Contains(sat, string(SMTPAuthDigestMD5)) {
|
|
||||||
return ErrDigestMD5AuthNotSupported
|
|
||||||
}
|
|
||||||
c.sa = smtp.CRAMMD5Auth(c.user, c.pass)
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported SMTP AUTH type %q", c.satype)
|
return fmt.Errorf("unsupported SMTP AUTH type %q", c.satype)
|
||||||
}
|
}
|
||||||
|
|
35
cmd/main.go
35
cmd/main.go
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/wneessen/go-mail"
|
"github.com/wneessen/go-mail"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -16,42 +15,36 @@ func main() {
|
||||||
tu := os.Getenv("TEST_USER")
|
tu := os.Getenv("TEST_USER")
|
||||||
tp := os.Getenv("TEST_PASS")
|
tp := os.Getenv("TEST_PASS")
|
||||||
|
|
||||||
|
fa := "Winni Neessen <wn@neessen.cloud>"
|
||||||
|
toa := "Winfried Neessen <wn@neessen.net>"
|
||||||
|
//toa = "Winfried Neessen <wneessen@arch-vm.fritz.box>"
|
||||||
|
|
||||||
m := mail.NewMsg()
|
m := mail.NewMsg()
|
||||||
if err := m.From(`Winni Neessen <wn@neessen.net>`); err != nil {
|
if err := m.From(fa); err != nil {
|
||||||
fmt.Printf("failed to set FROM addres: %s", err)
|
fmt.Printf("failed to set FROM addres: %s", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if err := m.To("t1+2941@test.de", "foo@bar.de", "blubb@blah.com"); err != nil {
|
if err := m.To(toa); err != nil {
|
||||||
fmt.Printf("failed to set TO address: %s", err)
|
fmt.Printf("failed to set TO address: %s", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
m.CcIgnoreInvalid("cc@test.de", "bar.de", "cc@blah.com")
|
|
||||||
m.BccIgnoreInvalid("bcc@test.de", "bcc@blah.com")
|
|
||||||
m.Subject("This is the Subject with Umlauts: üöäß")
|
m.Subject("This is the Subject with Umlauts: üöäß")
|
||||||
m.SetHeader(mail.HeaderContentLang, "en")
|
m.SetHeader(mail.HeaderContentLang, "de", "en", "fr", "sp", "de", "en", "fr", "sp", "de", "en", "fr",
|
||||||
|
"sp", "de", "en", "fr", "sp")
|
||||||
|
m.SetHeader(mail.HeaderListUnsubscribePost, "üüüüüüüü", "aaaaääää", "ßßßßßßßßß", "XXXXXX", "ZZZZZ", "XXXXXXXX",
|
||||||
|
"äää äää", "YYYYYY", "XXXXXX", "ZZZZZ", "üäö´")
|
||||||
m.SetMessageID()
|
m.SetMessageID()
|
||||||
m.SetDate()
|
m.SetDate()
|
||||||
m.SetBulk()
|
m.SetBulk()
|
||||||
m.Header()
|
|
||||||
|
|
||||||
c, err := mail.NewClient(th, mail.WithTimeout(time.Millisecond*500), mail.WithTLSPolicy(mail.TLSMandatory),
|
c, err := mail.NewClient(th, mail.WithTLSPolicy(mail.TLSMandatory),
|
||||||
mail.WithSMTPAuth(mail.SMTPAuthDigestMD5), mail.WithUsername(tu), mail.WithPassword(tp))
|
mail.WithSMTPAuth(mail.SMTPAuthLogin), mail.WithUsername(tu),
|
||||||
|
mail.WithPassword(tp))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("failed to create new client: %s\n", err)
|
fmt.Printf("failed to create new client: %s\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer func() {
|
if err := c.DialAndSend(m); err != nil {
|
||||||
if err := c.Reset(); err != nil {
|
|
||||||
fmt.Printf("failed to reset: %s\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := c.Close(); err != nil {
|
|
||||||
fmt.Printf("failed to close: %s\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := c.DialAndSend(); err != nil {
|
|
||||||
fmt.Printf("failed to dial: %s\n", err)
|
fmt.Printf("failed to dial: %s\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
package mail
|
package mail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"mime"
|
"mime"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
@ -9,6 +11,14 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNoFromAddress should be used when a FROM address is requrested but not set
|
||||||
|
ErrNoFromAddress = errors.New("no FROM address set")
|
||||||
|
|
||||||
|
// ErrNoRcptAddresses should be used when the list of RCPTs is empty
|
||||||
|
ErrNoRcptAddresses = errors.New("no recipient addresses set")
|
||||||
|
)
|
||||||
|
|
||||||
// Msg is the mail message struct
|
// Msg is the mail message struct
|
||||||
type Msg struct {
|
type Msg struct {
|
||||||
// charset represents the charset of the mail (defaults to UTF-8)
|
// charset represents the charset of the mail (defaults to UTF-8)
|
||||||
|
@ -167,7 +177,7 @@ func (m *Msg) SetMessageID() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hn = "localhost.localdomain"
|
hn = "localhost.localdomain"
|
||||||
}
|
}
|
||||||
ct := time.Now().UnixMicro()
|
ct := time.Now().Unix()
|
||||||
r := rand.New(rand.NewSource(ct))
|
r := rand.New(rand.NewSource(ct))
|
||||||
rn := r.Int()
|
rn := r.Int()
|
||||||
pid := os.Getpid()
|
pid := os.Getpid()
|
||||||
|
@ -194,18 +204,40 @@ func (m *Msg) SetDate() {
|
||||||
m.SetHeader(HeaderDate, ts)
|
m.SetHeader(HeaderDate, ts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header does something
|
// GetSender returns the currently set FROM address. If f is true, it will return the full
|
||||||
// FIXME: This is only here to quickly show the set headers for debugging purpose. Remove me later
|
// address string including the address name, if set
|
||||||
func (m *Msg) Header() {
|
func (m *Msg) GetSender(ff bool) (string, error) {
|
||||||
fmt.Println("Address header:")
|
f, ok := m.addrHeader[HeaderFrom]
|
||||||
for k, v := range m.addrHeader {
|
if !ok || len(f) == 0 {
|
||||||
fmt.Printf(" - %s: %s\n", k, v)
|
return "", ErrNoFromAddress
|
||||||
}
|
}
|
||||||
fmt.Println("\nGeneric header:")
|
if ff {
|
||||||
for k, v := range m.genHeader {
|
return f[0].String(), nil
|
||||||
fmt.Printf(" - %s: %s\n", k, v)
|
|
||||||
}
|
}
|
||||||
fmt.Println()
|
return f[0].Address, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecipients returns a list of the currently set TO/CC/BCC addresses.
|
||||||
|
func (m *Msg) GetRecipients() ([]string, error) {
|
||||||
|
var rl []string
|
||||||
|
for _, t := range []AddrHeader{HeaderTo, HeaderCc, HeaderBcc} {
|
||||||
|
al, ok := m.addrHeader[t]
|
||||||
|
if !ok || len(al) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, r := range al {
|
||||||
|
rl = append(rl, r.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rl) <= 0 {
|
||||||
|
return rl, ErrNoRcptAddresses
|
||||||
|
}
|
||||||
|
return rl, nil
|
||||||
|
}
|
||||||
|
func (m *Msg) Write(w io.Writer) (int64, error) {
|
||||||
|
mw := &msgWriter{w: w}
|
||||||
|
mw.writeMsg(m)
|
||||||
|
return mw.n, mw.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// setEncoder creates a new mime.WordEncoder based on the encoding setting of the message
|
// setEncoder creates a new mime.WordEncoder based on the encoding setting of the message
|
96
msgwriter.go
Normal file
96
msgwriter.go
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxHeaderLength defines the maximum line length for a mail header
|
||||||
|
// RFC 2047 suggests 76 characters
|
||||||
|
const MaxHeaderLength = 76
|
||||||
|
|
||||||
|
// msgWriter handles the I/O to the io.WriteCloser of the SMTP client
|
||||||
|
type msgWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
n int64
|
||||||
|
//writers [3]*multipart.Writer
|
||||||
|
//partWriter io.Writer
|
||||||
|
//depth uint8
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMsg formats the message and sends it to its io.Writer
|
||||||
|
func (mw *msgWriter) writeMsg(m *Msg) {
|
||||||
|
if _, ok := m.genHeader["Date"]; !ok {
|
||||||
|
m.SetDate()
|
||||||
|
}
|
||||||
|
for k, v := range m.genHeader {
|
||||||
|
mw.writeHeader(k, v...)
|
||||||
|
}
|
||||||
|
for _, t := range []AddrHeader{HeaderFrom, HeaderTo, HeaderCc} {
|
||||||
|
if al, ok := m.addrHeader[t]; ok {
|
||||||
|
var v []string
|
||||||
|
for _, a := range al {
|
||||||
|
v = append(v, a.String())
|
||||||
|
}
|
||||||
|
mw.writeHeader(Header(t), v...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mw.writeString("\r\n")
|
||||||
|
mw.writeString("This is a test mail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeHeader writes a header into the msgWriter's io.Writer
|
||||||
|
func (mw *msgWriter) writeHeader(k Header, v ...string) {
|
||||||
|
mw.writeString(string(k))
|
||||||
|
if len(v) == 0 {
|
||||||
|
mw.writeString(":\r\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mw.writeString(": ")
|
||||||
|
|
||||||
|
// Chars left: MaxHeaderLength - "<Headername>: " - "CRLF"
|
||||||
|
cl := MaxHeaderLength - len(k) - 4
|
||||||
|
for i, s := range v {
|
||||||
|
nfl := 0
|
||||||
|
if i < len(v) {
|
||||||
|
nfl = len(v[i])
|
||||||
|
}
|
||||||
|
if cl-len(s) < 1 {
|
||||||
|
if p := strings.IndexByte(s, ' '); p != -1 {
|
||||||
|
mw.writeString(s[:p])
|
||||||
|
mw.writeString("\r\n ")
|
||||||
|
mw.writeString(s[p:])
|
||||||
|
cl -= len(s[p:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cl < 1 || cl-nfl < 1 {
|
||||||
|
mw.writeString("\r\n")
|
||||||
|
cl = MaxHeaderLength - 4
|
||||||
|
if i != len(v) {
|
||||||
|
mw.writeString(" ")
|
||||||
|
cl -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mw.writeString(s)
|
||||||
|
cl -= len(s)
|
||||||
|
|
||||||
|
if i != len(v)-1 {
|
||||||
|
mw.writeString(", ")
|
||||||
|
cl -= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
mw.writeString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeString writes a string into the msgWriter's io.Writer interface
|
||||||
|
func (mw *msgWriter) writeString(s string) {
|
||||||
|
if mw.err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
n, mw.err = io.WriteString(mw.w, s)
|
||||||
|
mw.n += int64(n)
|
||||||
|
}
|
Loading…
Reference in a new issue