├── .travis.yml ├── LICENSE.md ├── README.md ├── protocol.go ├── protocol_test.go ├── reply.go ├── reply_test.go └── state.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.6 4 | - tip 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ian Kent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MailHog SMTP Protocol [![GoDoc](https://godoc.org/github.com/mailhog/smtp?status.svg)](https://godoc.org/github.com/mailhog/smtp) [![Build Status](https://travis-ci.org/mailhog/smtp.svg?branch=master)](https://travis-ci.org/mailhog/smtp) 2 | ========= 3 | 4 | `github.com/mailhog/smtp` implements an SMTP server state machine. 5 | 6 | It attempts to encapsulate as much of the SMTP protocol (plus its extensions) as possible 7 | without compromising configurability or requiring specific backend implementations. 8 | 9 | * ESMTP server implementing [RFC5321](http://tools.ietf.org/html/rfc5321) 10 | * Support for: 11 | * AUTH [RFC4954](http://tools.ietf.org/html/rfc4954) 12 | * PIPELINING [RFC2920](http://tools.ietf.org/html/rfc2920) 13 | * STARTTLS [RFC3207](http://tools.ietf.org/html/rfc3207) 14 | 15 | ```go 16 | proto := NewProtocol() 17 | reply := proto.Start() 18 | reply = proto.ProcessCommand("EHLO localhost") 19 | // ... 20 | ``` 21 | 22 | See [MailHog-Server](https://github.com/mailhog/MailHog-Server) and [MailHog-MTA](https://github.com/mailhog/MailHog-MTA) for example implementations. 23 | 24 | ### Commands and replies 25 | 26 | Interaction with the state machine is via: 27 | * the `Parse` function 28 | * the `ProcessCommand` and `ProcessData` functions 29 | 30 | You can mix the use of all three functions as necessary. 31 | 32 | #### Parse 33 | 34 | `Parse` should be used on a raw text stream. It looks for an end of line (`\r\n`), and if found, processes a single command. Any unprocessed data is returned. 35 | 36 | If any unprocessed data is returned, `Parse` should be 37 | called again to process then next command. 38 | 39 | ```go 40 | text := "EHLO localhost\r\nMAIL FROM:\r\nDATA\r\nTest\r\n.\r\n" 41 | 42 | var reply *smtp.Reply 43 | for { 44 | text, reply = proto.Parse(text) 45 | if len(text) == 0 { 46 | break 47 | } 48 | } 49 | ``` 50 | 51 | #### ProcessCommand and ProcessData 52 | 53 | `ProcessCommand` should be used for an already parsed command (i.e., a complete 54 | SMTP "line" excluding the line ending). 55 | 56 | `ProcessData` should be used if the protocol is in `DATA` state. 57 | 58 | ```go 59 | reply = proto.ProcessCommand("EHLO localhost") 60 | reply = proto.ProcessCommand("MAIL FROM:") 61 | reply = proto.ProcessCommand("DATA") 62 | reply = proto.ProcessData("Test\r\n.\r\n") 63 | ``` 64 | 65 | ### Hooks 66 | 67 | The state machine provides hooks to manipulate its behaviour. 68 | 69 | See [![GoDoc](https://godoc.org/github.com/mailhog/smtp?status.svg)](https://godoc.org/github.com/mailhog/smtp) for more information. 70 | 71 | | Hook | Description 72 | | ---------------------------------- | ----------- 73 | | LogHandler | Called for every log message 74 | | MessageReceivedHandler | Called for each message received 75 | | ValidateSenderHandler | Called after MAIL FROM 76 | | ValidateRecipientHandler | Called after RCPT TO 77 | | ValidateAuthenticationHandler | Called after AUTH 78 | | SMTPVerbFilter | Called for every SMTP command processed 79 | | TLSHandler | Callback mashup called after STARTTLS 80 | | GetAuthenticationMechanismsHandler | Called for each EHLO command 81 | 82 | ### Behaviour flags 83 | 84 | The state machine also exports variables to control its behaviour: 85 | 86 | See [![GoDoc](https://godoc.org/github.com/mailhog/smtp?status.svg)](https://godoc.org/github.com/mailhog/smtp) for more information. 87 | 88 | | Variable | Description 89 | | ---------------------- | ----------- 90 | | RejectBrokenRCPTSyntax | Reject non-conforming RCPT syntax 91 | | RejectBrokenMAILSyntax | Reject non-conforming MAIL syntax 92 | | RequireTLS | Require STARTTLS before other commands 93 | | MaximumRecipients | Maximum recipients per message 94 | | MaximumLineLength | Maximum length of SMTP line 95 | 96 | ### Licence 97 | 98 | Copyright ©‎ 2014-2015, Ian Kent (http://iankent.uk) 99 | 100 | Released under MIT license, see [LICENSE](LICENSE.md) for details. 101 | -------------------------------------------------------------------------------- /protocol.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | // http://www.rfc-editor.org/rfc/rfc5321.txt 4 | 5 | import ( 6 | "encoding/base64" 7 | "errors" 8 | "log" 9 | "regexp" 10 | "strings" 11 | 12 | "github.com/mailhog/data" 13 | ) 14 | 15 | // Command is a struct representing an SMTP command (verb + arguments) 16 | type Command struct { 17 | verb string 18 | args string 19 | orig string 20 | } 21 | 22 | // ParseCommand returns a Command from the line string 23 | func ParseCommand(line string) *Command { 24 | words := strings.Split(line, " ") 25 | command := strings.ToUpper(words[0]) 26 | args := strings.Join(words[1:len(words)], " ") 27 | 28 | return &Command{ 29 | verb: command, 30 | args: args, 31 | orig: line, 32 | } 33 | } 34 | 35 | // Protocol is a state machine representing an SMTP session 36 | type Protocol struct { 37 | lastCommand *Command 38 | 39 | TLSPending bool 40 | TLSUpgraded bool 41 | 42 | State State 43 | Message *data.SMTPMessage 44 | 45 | Hostname string 46 | Ident string 47 | 48 | MaximumLineLength int 49 | MaximumRecipients int 50 | 51 | // LogHandler is called for each log message. If nil, log messages will 52 | // be output using log.Printf instead. 53 | LogHandler func(message string, args ...interface{}) 54 | // MessageReceivedHandler is called for each message accepted by the 55 | // SMTP protocol. It must return a MessageID or error. If nil, messages 56 | // will be rejected with an error. 57 | MessageReceivedHandler func(*data.SMTPMessage) (string, error) 58 | // ValidateSenderHandler should return true if the sender is valid, 59 | // otherwise false. If nil, all senders will be accepted. 60 | ValidateSenderHandler func(from string) bool 61 | // ValidateRecipientHandler should return true if the recipient is valid, 62 | // otherwise false. If nil, all recipients will be accepted. 63 | ValidateRecipientHandler func(to string) bool 64 | // ValidateAuthenticationhandler should return true if the authentication 65 | // parameters are valid, otherwise false. If nil, all authentication 66 | // attempts will be accepted. 67 | ValidateAuthenticationHandler func(mechanism string, args ...string) (errorReply *Reply, ok bool) 68 | // SMTPVerbFilter is called after each command is parsed, but before 69 | // any code is executed. This provides an opportunity to reject unwanted verbs, 70 | // e.g. to require AUTH before MAIL 71 | SMTPVerbFilter func(verb string, args ...string) (errorReply *Reply) 72 | // TLSHandler is called when a STARTTLS command is received. 73 | // 74 | // It should acknowledge the TLS request and set ok to true. 75 | // It should also return a callback which will be invoked after the reply is 76 | // sent. E.g., a TCP connection can only perform the upgrade after sending the reply 77 | // 78 | // Once the upgrade is complete, invoke the done function (e.g., from the returned callback) 79 | // 80 | // If TLS upgrade isn't possible, return an errorReply and set ok to false. 81 | TLSHandler func(done func(ok bool)) (errorReply *Reply, callback func(), ok bool) 82 | 83 | // GetAuthenticationMechanismsHandler should return an array of strings 84 | // listing accepted authentication mechanisms 85 | GetAuthenticationMechanismsHandler func() []string 86 | 87 | // RejectBrokenRCPTSyntax controls whether the protocol accepts technically 88 | // invalid syntax for the RCPT command. Set to true, the RCPT syntax requires 89 | // no space between `TO:` and the opening `<` 90 | RejectBrokenRCPTSyntax bool 91 | // RejectBrokenMAILSyntax controls whether the protocol accepts technically 92 | // invalid syntax for the MAIL command. Set to true, the MAIL syntax requires 93 | // no space between `FROM:` and the opening `<` 94 | RejectBrokenMAILSyntax bool 95 | // RequireTLS controls whether TLS is required for a connection before other 96 | // commands can be issued, applied at the protocol layer. 97 | RequireTLS bool 98 | } 99 | 100 | // NewProtocol returns a new SMTP state machine in INVALID state 101 | // handler is called when a message is received and should return a message ID 102 | func NewProtocol() *Protocol { 103 | p := &Protocol{ 104 | Hostname: "mailhog.example", 105 | Ident: "ESMTP MailHog", 106 | State: INVALID, 107 | MaximumLineLength: -1, 108 | MaximumRecipients: -1, 109 | } 110 | p.resetState() 111 | return p 112 | } 113 | 114 | func (proto *Protocol) resetState() { 115 | proto.Message = &data.SMTPMessage{} 116 | } 117 | 118 | func (proto *Protocol) logf(message string, args ...interface{}) { 119 | message = strings.Join([]string{"[PROTO: %s]", message}, " ") 120 | args = append([]interface{}{StateMap[proto.State]}, args...) 121 | 122 | if proto.LogHandler != nil { 123 | proto.LogHandler(message, args...) 124 | } else { 125 | log.Printf(message, args...) 126 | } 127 | } 128 | 129 | // Start begins an SMTP conversation with a 220 reply, placing the state 130 | // machine in ESTABLISH state. 131 | func (proto *Protocol) Start() *Reply { 132 | proto.logf("Started session, switching to ESTABLISH state") 133 | proto.State = ESTABLISH 134 | return ReplyIdent(proto.Hostname + " " + proto.Ident) 135 | } 136 | 137 | // Parse parses a line string and returns any remaining line string 138 | // and a reply, if a command was found. Parse does nothing until a 139 | // new line is found. 140 | // - TODO decide whether to move this to a buffer inside Protocol 141 | // sort of like it this way, since it gives control back to the caller 142 | func (proto *Protocol) Parse(line string) (string, *Reply) { 143 | var reply *Reply 144 | 145 | if !strings.Contains(line, "\r\n") { 146 | return line, reply 147 | } 148 | 149 | parts := strings.SplitN(line, "\r\n", 2) 150 | line = parts[1] 151 | 152 | if proto.MaximumLineLength > -1 { 153 | if len(parts[0]) > proto.MaximumLineLength { 154 | return line, ReplyLineTooLong() 155 | } 156 | } 157 | 158 | // TODO collapse AUTH states into separate processing 159 | if proto.State == DATA { 160 | reply = proto.ProcessData(parts[0]) 161 | } else { 162 | reply = proto.ProcessCommand(parts[0]) 163 | } 164 | 165 | return line, reply 166 | } 167 | 168 | // ProcessData handles content received (with newlines stripped) while 169 | // in the SMTP DATA state 170 | func (proto *Protocol) ProcessData(line string) (reply *Reply) { 171 | proto.Message.Data += line + "\r\n" 172 | 173 | if strings.HasSuffix(proto.Message.Data, "\r\n.\r\n") { 174 | proto.Message.Data = strings.Replace(proto.Message.Data, "\r\n..", "\r\n.", -1) 175 | 176 | proto.logf("Got EOF, storing message and switching to MAIL state") 177 | proto.Message.Data = strings.TrimSuffix(proto.Message.Data, "\r\n.\r\n") 178 | proto.State = MAIL 179 | 180 | defer proto.resetState() 181 | 182 | if proto.MessageReceivedHandler == nil { 183 | return ReplyStorageFailed("No storage backend") 184 | } 185 | 186 | id, err := proto.MessageReceivedHandler(proto.Message) 187 | if err != nil { 188 | proto.logf("Error storing message: %s", err) 189 | return ReplyStorageFailed("Unable to store message") 190 | } 191 | return ReplyOk("Ok: queued as " + id) 192 | } 193 | 194 | return 195 | } 196 | 197 | // ProcessCommand processes a line of text as a command 198 | // It expects the line string to be a properly formed SMTP verb and arguments 199 | func (proto *Protocol) ProcessCommand(line string) (reply *Reply) { 200 | line = strings.Trim(line, "\r\n") 201 | proto.logf("Processing line: %s", line) 202 | 203 | words := strings.Split(line, " ") 204 | command := strings.ToUpper(words[0]) 205 | args := strings.Join(words[1:len(words)], " ") 206 | proto.logf("In state %d, got command '%s', args '%s'", proto.State, command, args) 207 | 208 | cmd := ParseCommand(strings.TrimSuffix(line, "\r\n")) 209 | return proto.Command(cmd) 210 | } 211 | 212 | // Command applies an SMTP verb and arguments to the state machine 213 | func (proto *Protocol) Command(command *Command) (reply *Reply) { 214 | defer func() { 215 | proto.lastCommand = command 216 | }() 217 | if proto.SMTPVerbFilter != nil { 218 | proto.logf("sending to SMTP verb filter") 219 | r := proto.SMTPVerbFilter(command.verb) 220 | if r != nil { 221 | proto.logf("response returned by SMTP verb filter") 222 | return r 223 | } 224 | } 225 | switch { 226 | case proto.TLSPending && !proto.TLSUpgraded: 227 | proto.logf("Got command before TLS upgrade complete") 228 | // FIXME what to do? 229 | return ReplyBye() 230 | case "RSET" == command.verb: 231 | proto.logf("Got RSET command, switching to MAIL state") 232 | proto.State = MAIL 233 | proto.Message = &data.SMTPMessage{} 234 | return ReplyOk() 235 | case "NOOP" == command.verb: 236 | proto.logf("Got NOOP verb, staying in %s state", StateMap[proto.State]) 237 | return ReplyOk() 238 | case "QUIT" == command.verb: 239 | proto.logf("Got QUIT verb, staying in %s state", StateMap[proto.State]) 240 | proto.State = DONE 241 | return ReplyBye() 242 | case ESTABLISH == proto.State: 243 | proto.logf("In ESTABLISH state") 244 | switch command.verb { 245 | case "HELO": 246 | return proto.HELO(command.args) 247 | case "EHLO": 248 | return proto.EHLO(command.args) 249 | case "STARTTLS": 250 | return proto.STARTTLS(command.args) 251 | default: 252 | proto.logf("Got unknown command for ESTABLISH state: '%s'", command.verb) 253 | return ReplyUnrecognisedCommand() 254 | } 255 | case "STARTTLS" == command.verb: 256 | proto.logf("Got STARTTLS command outside ESTABLISH state") 257 | return proto.STARTTLS(command.args) 258 | case proto.RequireTLS && !proto.TLSUpgraded: 259 | proto.logf("RequireTLS set and not TLS not upgraded") 260 | return ReplyMustIssueSTARTTLSFirst() 261 | case AUTHPLAIN == proto.State: 262 | proto.logf("Got PLAIN authentication response: '%s', switching to MAIL state", command.args) 263 | proto.State = MAIL 264 | if proto.ValidateAuthenticationHandler != nil { 265 | // TODO error handling 266 | val, _ := base64.StdEncoding.DecodeString(command.orig) 267 | bits := strings.Split(string(val), string(rune(0))) 268 | 269 | if len(bits) < 3 { 270 | return ReplyError(errors.New("Badly formed parameter")) 271 | } 272 | 273 | user, pass := bits[1], bits[2] 274 | 275 | if reply, ok := proto.ValidateAuthenticationHandler("PLAIN", user, pass); !ok { 276 | return reply 277 | } 278 | } 279 | return ReplyAuthOk() 280 | case AUTHLOGIN == proto.State: 281 | proto.logf("Got LOGIN authentication response: '%s', switching to AUTHLOGIN2 state", command.args) 282 | proto.State = AUTHLOGIN2 283 | return ReplyAuthResponse("UGFzc3dvcmQ6") 284 | case AUTHLOGIN2 == proto.State: 285 | proto.logf("Got LOGIN authentication response: '%s', switching to MAIL state", command.args) 286 | proto.State = MAIL 287 | if proto.ValidateAuthenticationHandler != nil { 288 | if reply, ok := proto.ValidateAuthenticationHandler("LOGIN", proto.lastCommand.orig, command.orig); !ok { 289 | return reply 290 | } 291 | } 292 | return ReplyAuthOk() 293 | case AUTHCRAMMD5 == proto.State: 294 | proto.logf("Got CRAM-MD5 authentication response: '%s', switching to MAIL state", command.args) 295 | proto.State = MAIL 296 | if proto.ValidateAuthenticationHandler != nil { 297 | if reply, ok := proto.ValidateAuthenticationHandler("CRAM-MD5", command.orig); !ok { 298 | return reply 299 | } 300 | } 301 | return ReplyAuthOk() 302 | case MAIL == proto.State: 303 | proto.logf("In MAIL state") 304 | switch command.verb { 305 | case "AUTH": 306 | proto.logf("Got AUTH command, staying in MAIL state") 307 | switch { 308 | case strings.HasPrefix(command.args, "PLAIN "): 309 | proto.logf("Got PLAIN authentication: %s", strings.TrimPrefix(command.args, "PLAIN ")) 310 | if proto.ValidateAuthenticationHandler != nil { 311 | val, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(command.args, "PLAIN ")) 312 | bits := strings.Split(string(val), string(rune(0))) 313 | 314 | if len(bits) < 3 { 315 | return ReplyError(errors.New("Badly formed parameter")) 316 | } 317 | 318 | user, pass := bits[1], bits[2] 319 | 320 | if reply, ok := proto.ValidateAuthenticationHandler("PLAIN", user, pass); !ok { 321 | return reply 322 | } 323 | } 324 | return ReplyAuthOk() 325 | case "LOGIN" == command.args: 326 | proto.logf("Got LOGIN authentication, switching to AUTH state") 327 | proto.State = AUTHLOGIN 328 | return ReplyAuthResponse("VXNlcm5hbWU6") 329 | case "PLAIN" == command.args: 330 | proto.logf("Got PLAIN authentication (no args), switching to AUTH2 state") 331 | proto.State = AUTHPLAIN 332 | return ReplyAuthResponse("") 333 | case "CRAM-MD5" == command.args: 334 | proto.logf("Got CRAM-MD5 authentication, switching to AUTH state") 335 | proto.State = AUTHCRAMMD5 336 | return ReplyAuthResponse("PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4=") 337 | case strings.HasPrefix(command.args, "EXTERNAL "): 338 | proto.logf("Got EXTERNAL authentication: %s", strings.TrimPrefix(command.args, "EXTERNAL ")) 339 | if proto.ValidateAuthenticationHandler != nil { 340 | if reply, ok := proto.ValidateAuthenticationHandler("EXTERNAL", strings.TrimPrefix(command.args, "EXTERNAL ")); !ok { 341 | return reply 342 | } 343 | } 344 | return ReplyAuthOk() 345 | default: 346 | return ReplyUnsupportedAuth() 347 | } 348 | case "MAIL": 349 | proto.logf("Got MAIL command, switching to RCPT state") 350 | from, err := proto.ParseMAIL(command.args) 351 | if err != nil { 352 | return ReplyError(err) 353 | } 354 | if proto.ValidateSenderHandler != nil { 355 | if !proto.ValidateSenderHandler(from) { 356 | // TODO correct sender error response 357 | return ReplyError(errors.New("Invalid sender " + from)) 358 | } 359 | } 360 | proto.Message.From = from 361 | proto.State = RCPT 362 | return ReplySenderOk(from) 363 | case "HELO": 364 | return proto.HELO(command.args) 365 | case "EHLO": 366 | return proto.EHLO(command.args) 367 | default: 368 | proto.logf("Got unknown command for MAIL state: '%s'", command) 369 | return ReplyUnrecognisedCommand() 370 | } 371 | case RCPT == proto.State: 372 | proto.logf("In RCPT state") 373 | switch command.verb { 374 | case "RCPT": 375 | proto.logf("Got RCPT command") 376 | if proto.MaximumRecipients > -1 && len(proto.Message.To) >= proto.MaximumRecipients { 377 | return ReplyTooManyRecipients() 378 | } 379 | to, err := proto.ParseRCPT(command.args) 380 | if err != nil { 381 | return ReplyError(err) 382 | } 383 | if proto.ValidateRecipientHandler != nil { 384 | if !proto.ValidateRecipientHandler(to) { 385 | // TODO correct send error response 386 | return ReplyError(errors.New("Invalid recipient " + to)) 387 | } 388 | } 389 | proto.Message.To = append(proto.Message.To, to) 390 | proto.State = RCPT 391 | return ReplyRecipientOk(to) 392 | case "HELO": 393 | return proto.HELO(command.args) 394 | case "EHLO": 395 | return proto.EHLO(command.args) 396 | case "DATA": 397 | proto.logf("Got DATA command, switching to DATA state") 398 | proto.State = DATA 399 | return ReplyDataResponse() 400 | default: 401 | proto.logf("Got unknown command for RCPT state: '%s'", command) 402 | return ReplyUnrecognisedCommand() 403 | } 404 | default: 405 | proto.logf("Command not recognised") 406 | return ReplyUnrecognisedCommand() 407 | } 408 | } 409 | 410 | // HELO creates a reply to a HELO command 411 | func (proto *Protocol) HELO(args string) (reply *Reply) { 412 | proto.logf("Got HELO command, switching to MAIL state") 413 | proto.State = MAIL 414 | proto.Message.Helo = args 415 | return ReplyOk("Hello " + args) 416 | } 417 | 418 | // EHLO creates a reply to a EHLO command 419 | func (proto *Protocol) EHLO(args string) (reply *Reply) { 420 | proto.logf("Got EHLO command, switching to MAIL state") 421 | proto.State = MAIL 422 | proto.Message.Helo = args 423 | replyArgs := []string{"Hello " + args, "PIPELINING"} 424 | 425 | if proto.TLSHandler != nil && !proto.TLSPending && !proto.TLSUpgraded { 426 | replyArgs = append(replyArgs, "STARTTLS") 427 | } 428 | 429 | if !proto.RequireTLS || proto.TLSUpgraded { 430 | if proto.GetAuthenticationMechanismsHandler != nil { 431 | mechanisms := proto.GetAuthenticationMechanismsHandler() 432 | if len(mechanisms) > 0 { 433 | replyArgs = append(replyArgs, "AUTH "+strings.Join(mechanisms, " ")) 434 | } 435 | } 436 | } 437 | return ReplyOk(replyArgs...) 438 | } 439 | 440 | // STARTTLS creates a reply to a STARTTLS command 441 | func (proto *Protocol) STARTTLS(args string) (reply *Reply) { 442 | if proto.TLSUpgraded { 443 | return ReplyUnrecognisedCommand() 444 | } 445 | 446 | if proto.TLSHandler == nil { 447 | proto.logf("tls handler not found") 448 | return ReplyUnrecognisedCommand() 449 | } 450 | 451 | if len(args) > 0 { 452 | return ReplySyntaxError("no parameters allowed") 453 | } 454 | 455 | r, callback, ok := proto.TLSHandler(func(ok bool) { 456 | proto.TLSUpgraded = ok 457 | proto.TLSPending = ok 458 | if ok { 459 | proto.resetState() 460 | proto.State = ESTABLISH 461 | } 462 | }) 463 | if !ok { 464 | return r 465 | } 466 | 467 | proto.TLSPending = true 468 | return ReplyReadyToStartTLS(callback) 469 | } 470 | 471 | var parseMailBrokenRegexp = regexp.MustCompile("(?i:From):\\s*<([^>]+)>") 472 | var parseMailRFCRegexp = regexp.MustCompile("(?i:From):<([^>]+)>") 473 | 474 | // ParseMAIL returns the forward-path from a MAIL command argument 475 | func (proto *Protocol) ParseMAIL(mail string) (string, error) { 476 | var match []string 477 | if proto.RejectBrokenMAILSyntax { 478 | match = parseMailRFCRegexp.FindStringSubmatch(mail) 479 | } else { 480 | match = parseMailBrokenRegexp.FindStringSubmatch(mail) 481 | } 482 | 483 | if len(match) != 2 { 484 | return "", errors.New("Invalid syntax in MAIL command") 485 | } 486 | return match[1], nil 487 | } 488 | 489 | var parseRcptBrokenRegexp = regexp.MustCompile("(?i:To):\\s*<([^>]+)>") 490 | var parseRcptRFCRegexp = regexp.MustCompile("(?i:To):<([^>]+)>") 491 | 492 | // ParseRCPT returns the return-path from a RCPT command argument 493 | func (proto *Protocol) ParseRCPT(rcpt string) (string, error) { 494 | var match []string 495 | if proto.RejectBrokenRCPTSyntax { 496 | match = parseRcptRFCRegexp.FindStringSubmatch(rcpt) 497 | } else { 498 | match = parseRcptBrokenRegexp.FindStringSubmatch(rcpt) 499 | } 500 | if len(match) != 2 { 501 | return "", errors.New("Invalid syntax in RCPT command") 502 | } 503 | return match[1], nil 504 | } 505 | -------------------------------------------------------------------------------- /protocol_test.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | // http://www.rfc-editor.org/rfc/rfc5321.txt 4 | 5 | import ( 6 | "errors" 7 | "testing" 8 | 9 | "github.com/mailhog/data" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | func TestProtocol(t *testing.T) { 14 | Convey("NewProtocol returns a new Protocol", t, func() { 15 | proto := NewProtocol() 16 | So(proto, ShouldNotBeNil) 17 | So(proto, ShouldHaveSameTypeAs, &Protocol{}) 18 | So(proto.Hostname, ShouldEqual, "mailhog.example") 19 | So(proto.Ident, ShouldEqual, "ESMTP MailHog") 20 | So(proto.State, ShouldEqual, INVALID) 21 | So(proto.Message, ShouldNotBeNil) 22 | So(proto.Message, ShouldHaveSameTypeAs, &data.SMTPMessage{}) 23 | }) 24 | 25 | Convey("LogHandler should be called for logging", t, func() { 26 | proto := NewProtocol() 27 | handlerCalled := false 28 | proto.LogHandler = func(message string, args ...interface{}) { 29 | handlerCalled = true 30 | So(message, ShouldEqual, "[PROTO: %s] Test message %s %s") 31 | So(len(args), ShouldEqual, 3) 32 | So(args[0], ShouldEqual, "INVALID") 33 | So(args[1], ShouldEqual, "test arg 1") 34 | So(args[2], ShouldEqual, "test arg 2") 35 | } 36 | proto.logf("Test message %s %s", "test arg 1", "test arg 2") 37 | So(handlerCalled, ShouldBeTrue) 38 | }) 39 | 40 | Convey("Start should modify the state correctly", t, func() { 41 | proto := NewProtocol() 42 | So(proto.State, ShouldEqual, INVALID) 43 | reply := proto.Start() 44 | So(proto.State, ShouldEqual, ESTABLISH) 45 | So(reply, ShouldNotBeNil) 46 | So(reply, ShouldHaveSameTypeAs, &Reply{}) 47 | So(reply.Status, ShouldEqual, 220) 48 | So(reply.Lines(), ShouldResemble, []string{"220 mailhog.example ESMTP MailHog\r\n"}) 49 | }) 50 | 51 | Convey("Modifying the hostname should modify the ident reply", t, func() { 52 | proto := NewProtocol() 53 | proto.Ident = "OinkSMTP MailHog" 54 | reply := proto.Start() 55 | So(reply, ShouldNotBeNil) 56 | So(reply, ShouldHaveSameTypeAs, &Reply{}) 57 | So(reply.Status, ShouldEqual, 220) 58 | So(reply.Lines(), ShouldResemble, []string{"220 mailhog.example OinkSMTP MailHog\r\n"}) 59 | }) 60 | 61 | Convey("Modifying the ident should modify the ident reply", t, func() { 62 | proto := NewProtocol() 63 | proto.Hostname = "oink.oink" 64 | reply := proto.Start() 65 | So(reply, ShouldNotBeNil) 66 | So(reply, ShouldHaveSameTypeAs, &Reply{}) 67 | So(reply.Status, ShouldEqual, 220) 68 | So(reply.Lines(), ShouldResemble, []string{"220 oink.oink ESMTP MailHog\r\n"}) 69 | }) 70 | } 71 | 72 | func TestProcessCommand(t *testing.T) { 73 | Convey("ProcessCommand should attempt to process anything", t, func() { 74 | proto := NewProtocol() 75 | 76 | reply := proto.ProcessCommand("OINK mailhog.example") 77 | So(proto.State, ShouldEqual, INVALID) 78 | So(reply, ShouldNotBeNil) 79 | So(reply.Status, ShouldEqual, 500) 80 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 81 | 82 | proto.Start() 83 | So(proto.State, ShouldEqual, ESTABLISH) 84 | 85 | reply = proto.ProcessCommand("HELO localhost") 86 | So(proto.State, ShouldEqual, MAIL) 87 | So(reply, ShouldNotBeNil) 88 | So(reply.Status, ShouldEqual, 250) 89 | So(reply.Lines(), ShouldResemble, []string{"250 Hello localhost\r\n"}) 90 | 91 | reply = proto.ProcessCommand("OINK mailhog.example") 92 | So(proto.State, ShouldEqual, MAIL) 93 | So(reply, ShouldNotBeNil) 94 | So(reply.Status, ShouldEqual, 500) 95 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 96 | }) 97 | } 98 | 99 | func TestParse(t *testing.T) { 100 | Convey("Parse can parse partial and multiple commands", t, func() { 101 | proto := NewProtocol() 102 | proto.Start() 103 | So(proto.State, ShouldEqual, ESTABLISH) 104 | 105 | line, reply := proto.Parse("HELO localhost") 106 | So(proto.State, ShouldEqual, ESTABLISH) 107 | So(reply, ShouldBeNil) 108 | So(line, ShouldEqual, "HELO localhost") 109 | 110 | line, reply = proto.Parse("HELO localhost\r\nMAIL Fro") 111 | So(proto.State, ShouldEqual, MAIL) 112 | So(reply, ShouldNotBeNil) 113 | So(line, ShouldEqual, "MAIL Fro") 114 | 115 | line, reply = proto.Parse("MAIL From:\r\n") 116 | So(proto.State, ShouldEqual, RCPT) 117 | So(reply, ShouldNotBeNil) 118 | So(line, ShouldEqual, "") 119 | }) 120 | Convey("Parse can call ProcessData", t, func() { 121 | proto := NewProtocol() 122 | proto.Start() 123 | proto.Command(ParseCommand("EHLO localhost")) 124 | proto.Command(ParseCommand("MAIL From:")) 125 | proto.Command(ParseCommand("RCPT To:")) 126 | proto.Command(ParseCommand("DATA")) 127 | So(proto.State, ShouldEqual, DATA) 128 | 129 | // FIXME this test relies on mailhog/data, it shouldn't! 130 | 131 | line, reply := proto.Parse("Content-Type: text/plain;\r\n") 132 | So(proto.State, ShouldEqual, DATA) 133 | So(line, ShouldEqual, "") 134 | So(proto.Message.Data, ShouldEqual, "Content-Type: text/plain;\r\n") 135 | So(reply, ShouldBeNil) 136 | 137 | line, reply = proto.Parse("\r\n") 138 | So(proto.State, ShouldEqual, DATA) 139 | So(line, ShouldEqual, "") 140 | So(proto.Message.Data, ShouldEqual, "Content-Type: text/plain;\r\n\r\n") 141 | So(reply, ShouldBeNil) 142 | 143 | line, reply = proto.Parse("Hi\r\n") 144 | So(proto.State, ShouldEqual, DATA) 145 | So(line, ShouldEqual, "") 146 | So(proto.Message.Data, ShouldEqual, "Content-Type: text/plain;\r\n\r\nHi\r\n") 147 | So(reply, ShouldBeNil) 148 | 149 | line, reply = proto.Parse("\r\n") 150 | So(proto.State, ShouldEqual, DATA) 151 | So(line, ShouldEqual, "") 152 | So(proto.Message.Data, ShouldEqual, "Content-Type: text/plain;\r\n\r\nHi\r\n\r\n") 153 | So(reply, ShouldBeNil) 154 | 155 | line, reply = proto.Parse(".\r\n") 156 | So(proto.State, ShouldEqual, MAIL) 157 | So(line, ShouldEqual, "") 158 | So(reply, ShouldNotBeNil) 159 | So(proto.Message.Data, ShouldEqual, "") 160 | }) 161 | } 162 | 163 | func TestUnknownCommands(t *testing.T) { 164 | Convey("Unknown command in INVALID state", t, func() { 165 | proto := NewProtocol() 166 | So(proto.State, ShouldEqual, INVALID) 167 | reply := proto.Command(ParseCommand("OINK")) 168 | So(reply, ShouldNotBeNil) 169 | So(reply.Status, ShouldEqual, 500) 170 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 171 | }) 172 | Convey("Unknown command in ESTABLISH state", t, func() { 173 | proto := NewProtocol() 174 | proto.Start() 175 | So(proto.State, ShouldEqual, ESTABLISH) 176 | reply := proto.Command(ParseCommand("OINK")) 177 | So(reply, ShouldNotBeNil) 178 | So(reply.Status, ShouldEqual, 500) 179 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 180 | }) 181 | Convey("Unknown command in MAIL state", t, func() { 182 | proto := NewProtocol() 183 | proto.Start() 184 | proto.Command(ParseCommand("EHLO localhost")) 185 | So(proto.State, ShouldEqual, MAIL) 186 | reply := proto.Command(ParseCommand("OINK")) 187 | So(reply, ShouldNotBeNil) 188 | So(reply.Status, ShouldEqual, 500) 189 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 190 | }) 191 | Convey("Unknown command in RCPT state", t, func() { 192 | proto := NewProtocol() 193 | proto.Start() 194 | proto.Command(ParseCommand("EHLO localhost")) 195 | proto.Command(ParseCommand("MAIL FROM:")) 196 | So(proto.State, ShouldEqual, RCPT) 197 | reply := proto.Command(ParseCommand("OINK")) 198 | So(reply, ShouldNotBeNil) 199 | So(reply.Status, ShouldEqual, 500) 200 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 201 | }) 202 | } 203 | 204 | func TestESTABLISHCommands(t *testing.T) { 205 | Convey("EHLO should work in ESTABLISH state", t, func() { 206 | proto := NewProtocol() 207 | proto.Start() 208 | So(proto.State, ShouldEqual, ESTABLISH) 209 | reply := proto.Command(ParseCommand("EHLO localhost")) 210 | So(reply, ShouldNotBeNil) 211 | So(reply.Status, ShouldEqual, 250) 212 | }) 213 | Convey("HELO should work in ESTABLISH state", t, func() { 214 | proto := NewProtocol() 215 | proto.Start() 216 | So(proto.State, ShouldEqual, ESTABLISH) 217 | reply := proto.Command(ParseCommand("HELO localhost")) 218 | So(reply, ShouldNotBeNil) 219 | So(reply.Status, ShouldEqual, 250) 220 | }) 221 | Convey("RSET should work in ESTABLISH state", t, func() { 222 | proto := NewProtocol() 223 | proto.Start() 224 | So(proto.State, ShouldEqual, ESTABLISH) 225 | reply := proto.Command(ParseCommand("RSET")) 226 | So(reply, ShouldNotBeNil) 227 | So(reply.Status, ShouldEqual, 250) 228 | }) 229 | Convey("NOOP should work in ESTABLISH state", t, func() { 230 | proto := NewProtocol() 231 | proto.Start() 232 | So(proto.State, ShouldEqual, ESTABLISH) 233 | reply := proto.Command(ParseCommand("NOOP")) 234 | So(reply, ShouldNotBeNil) 235 | So(reply.Status, ShouldEqual, 250) 236 | }) 237 | Convey("QUIT should work in ESTABLISH state", t, func() { 238 | proto := NewProtocol() 239 | proto.Start() 240 | So(proto.State, ShouldEqual, ESTABLISH) 241 | reply := proto.Command(ParseCommand("QUIT")) 242 | So(reply, ShouldNotBeNil) 243 | So(reply.Status, ShouldEqual, 221) 244 | }) 245 | Convey("MAIL shouldn't work in ESTABLISH state", t, func() { 246 | proto := NewProtocol() 247 | proto.Start() 248 | So(proto.State, ShouldEqual, ESTABLISH) 249 | reply := proto.Command(ParseCommand("MAIL")) 250 | So(reply, ShouldNotBeNil) 251 | So(reply.Status, ShouldEqual, 500) 252 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 253 | }) 254 | Convey("RCPT shouldn't work in ESTABLISH state", t, func() { 255 | proto := NewProtocol() 256 | proto.Start() 257 | So(proto.State, ShouldEqual, ESTABLISH) 258 | reply := proto.Command(ParseCommand("RCPT")) 259 | So(reply, ShouldNotBeNil) 260 | So(reply.Status, ShouldEqual, 500) 261 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 262 | }) 263 | Convey("DATA shouldn't work in ESTABLISH state", t, func() { 264 | proto := NewProtocol() 265 | proto.Start() 266 | So(proto.State, ShouldEqual, ESTABLISH) 267 | reply := proto.Command(ParseCommand("DATA")) 268 | So(reply, ShouldNotBeNil) 269 | So(reply.Status, ShouldEqual, 500) 270 | So(reply.Lines(), ShouldResemble, []string{"500 Unrecognised command\r\n"}) 271 | }) 272 | } 273 | 274 | func TestEHLO(t *testing.T) { 275 | Convey("EHLO should modify the state correctly", t, func() { 276 | proto := NewProtocol() 277 | proto.Start() 278 | So(proto.State, ShouldEqual, ESTABLISH) 279 | So(proto.Message.Helo, ShouldEqual, "") 280 | reply := proto.EHLO("localhost") 281 | So(reply, ShouldNotBeNil) 282 | So(reply.Status, ShouldEqual, 250) 283 | So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"}) 284 | So(proto.State, ShouldEqual, MAIL) 285 | So(proto.Message.Helo, ShouldEqual, "localhost") 286 | }) 287 | Convey("EHLO should work using Command", t, func() { 288 | proto := NewProtocol() 289 | proto.Start() 290 | So(proto.State, ShouldEqual, ESTABLISH) 291 | So(proto.Message.Helo, ShouldEqual, "") 292 | reply := proto.Command(ParseCommand("EHLO localhost")) 293 | So(reply, ShouldNotBeNil) 294 | So(reply.Status, ShouldEqual, 250) 295 | So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"}) 296 | So(proto.State, ShouldEqual, MAIL) 297 | So(proto.Message.Helo, ShouldEqual, "localhost") 298 | }) 299 | Convey("HELO should work in MAIL state", t, func() { 300 | proto := NewProtocol() 301 | proto.Start() 302 | proto.Command(ParseCommand("HELO localhost")) 303 | So(proto.State, ShouldEqual, MAIL) 304 | So(proto.Message.Helo, ShouldEqual, "localhost") 305 | reply := proto.Command(ParseCommand("EHLO localhost")) 306 | So(reply, ShouldNotBeNil) 307 | So(reply.Status, ShouldEqual, 250) 308 | So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"}) 309 | So(proto.State, ShouldEqual, MAIL) 310 | So(proto.Message.Helo, ShouldEqual, "localhost") 311 | }) 312 | Convey("HELO should work in RCPT state", t, func() { 313 | proto := NewProtocol() 314 | proto.Start() 315 | proto.Command(ParseCommand("HELO localhost")) 316 | proto.Command(ParseCommand("MAIL From:")) 317 | So(proto.State, ShouldEqual, RCPT) 318 | So(proto.Message.Helo, ShouldEqual, "localhost") 319 | reply := proto.Command(ParseCommand("EHLO localhost")) 320 | So(reply, ShouldNotBeNil) 321 | So(reply.Status, ShouldEqual, 250) 322 | So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"}) 323 | So(proto.State, ShouldEqual, MAIL) 324 | So(proto.Message.Helo, ShouldEqual, "localhost") 325 | }) 326 | } 327 | 328 | func TestHELO(t *testing.T) { 329 | Convey("HELO should modify the state correctly", t, func() { 330 | proto := NewProtocol() 331 | proto.Start() 332 | So(proto.State, ShouldEqual, ESTABLISH) 333 | So(proto.Message.Helo, ShouldEqual, "") 334 | reply := proto.HELO("localhost") 335 | So(reply, ShouldNotBeNil) 336 | So(reply.Status, ShouldEqual, 250) 337 | So(reply.Lines(), ShouldResemble, []string{"250 Hello localhost\r\n"}) 338 | So(proto.State, ShouldEqual, MAIL) 339 | So(proto.Message.Helo, ShouldEqual, "localhost") 340 | }) 341 | Convey("HELO should work using Command", t, func() { 342 | proto := NewProtocol() 343 | proto.Start() 344 | So(proto.State, ShouldEqual, ESTABLISH) 345 | So(proto.Message.Helo, ShouldEqual, "") 346 | reply := proto.Command(ParseCommand("HELO localhost")) 347 | So(reply, ShouldNotBeNil) 348 | So(reply.Status, ShouldEqual, 250) 349 | So(reply.Lines(), ShouldResemble, []string{"250 Hello localhost\r\n"}) 350 | So(proto.State, ShouldEqual, MAIL) 351 | So(proto.Message.Helo, ShouldEqual, "localhost") 352 | }) 353 | Convey("HELO should work in MAIL state", t, func() { 354 | proto := NewProtocol() 355 | proto.Start() 356 | proto.Command(ParseCommand("HELO localhost")) 357 | So(proto.State, ShouldEqual, MAIL) 358 | So(proto.Message.Helo, ShouldEqual, "localhost") 359 | reply := proto.Command(ParseCommand("HELO localhost")) 360 | So(reply, ShouldNotBeNil) 361 | So(reply.Status, ShouldEqual, 250) 362 | So(reply.Lines(), ShouldResemble, []string{"250 Hello localhost\r\n"}) 363 | So(proto.State, ShouldEqual, MAIL) 364 | So(proto.Message.Helo, ShouldEqual, "localhost") 365 | }) 366 | Convey("HELO should work in RCPT state", t, func() { 367 | proto := NewProtocol() 368 | proto.Start() 369 | proto.Command(ParseCommand("HELO localhost")) 370 | proto.Command(ParseCommand("MAIL From:")) 371 | So(proto.State, ShouldEqual, RCPT) 372 | So(proto.Message.Helo, ShouldEqual, "localhost") 373 | reply := proto.Command(ParseCommand("HELO localhost")) 374 | So(reply, ShouldNotBeNil) 375 | So(reply.Status, ShouldEqual, 250) 376 | So(reply.Lines(), ShouldResemble, []string{"250 Hello localhost\r\n"}) 377 | So(proto.State, ShouldEqual, MAIL) 378 | So(proto.Message.Helo, ShouldEqual, "localhost") 379 | }) 380 | } 381 | 382 | func TestDATA(t *testing.T) { 383 | Convey("DATA should accept data", t, func() { 384 | proto := NewProtocol() 385 | handlerCalled := false 386 | proto.MessageReceivedHandler = func(msg *data.SMTPMessage) (string, error) { 387 | handlerCalled = true 388 | return "abc", nil 389 | } 390 | proto.Start() 391 | proto.HELO("localhost") 392 | proto.Command(ParseCommand("MAIL FROM:")) 393 | proto.Command(ParseCommand("RCPT TO:")) 394 | reply := proto.Command(ParseCommand("DATA")) 395 | So(reply, ShouldNotBeNil) 396 | So(reply.Status, ShouldEqual, 354) 397 | So(reply.Lines(), ShouldResemble, []string{"354 End data with .\r\n"}) 398 | So(proto.State, ShouldEqual, DATA) 399 | reply = proto.ProcessData("Hi") 400 | So(reply, ShouldBeNil) 401 | So(proto.State, ShouldEqual, DATA) 402 | So(proto.Message.Data, ShouldEqual, "Hi\r\n") 403 | reply = proto.ProcessData("How are you?") 404 | So(reply, ShouldBeNil) 405 | So(proto.State, ShouldEqual, DATA) 406 | So(proto.Message.Data, ShouldEqual, "Hi\r\nHow are you?\r\n") 407 | reply = proto.ProcessData("\r\n.") 408 | So(reply, ShouldNotBeNil) 409 | So(reply.Status, ShouldEqual, 250) 410 | So(proto.State, ShouldEqual, MAIL) 411 | So(reply.Lines(), ShouldResemble, []string{"250 Ok: queued as abc\r\n"}) 412 | So(handlerCalled, ShouldBeTrue) 413 | }) 414 | Convey("Should return error if missing storage backend", t, func() { 415 | proto := NewProtocol() 416 | proto.Start() 417 | proto.HELO("localhost") 418 | proto.Command(ParseCommand("MAIL FROM:")) 419 | proto.Command(ParseCommand("RCPT TO:")) 420 | reply := proto.Command(ParseCommand("DATA")) 421 | So(reply, ShouldNotBeNil) 422 | So(reply.Status, ShouldEqual, 354) 423 | So(reply.Lines(), ShouldResemble, []string{"354 End data with .\r\n"}) 424 | So(proto.State, ShouldEqual, DATA) 425 | reply = proto.ProcessData("Hi") 426 | So(reply, ShouldBeNil) 427 | So(proto.State, ShouldEqual, DATA) 428 | So(proto.Message.Data, ShouldEqual, "Hi\r\n") 429 | reply = proto.ProcessData("How are you?") 430 | So(reply, ShouldBeNil) 431 | So(proto.State, ShouldEqual, DATA) 432 | So(proto.Message.Data, ShouldEqual, "Hi\r\nHow are you?\r\n") 433 | reply = proto.ProcessData("\r\n.") 434 | So(reply, ShouldNotBeNil) 435 | So(reply.Status, ShouldEqual, 452) 436 | So(proto.State, ShouldEqual, MAIL) 437 | So(reply.Lines(), ShouldResemble, []string{"452 No storage backend\r\n"}) 438 | }) 439 | Convey("Should return error if storage backend fails", t, func() { 440 | proto := NewProtocol() 441 | handlerCalled := false 442 | proto.MessageReceivedHandler = func(msg *data.SMTPMessage) (string, error) { 443 | handlerCalled = true 444 | return "", errors.New("abc") 445 | } 446 | proto.Start() 447 | proto.HELO("localhost") 448 | proto.Command(ParseCommand("MAIL FROM:")) 449 | proto.Command(ParseCommand("RCPT TO:")) 450 | reply := proto.Command(ParseCommand("DATA")) 451 | So(reply, ShouldNotBeNil) 452 | So(reply.Status, ShouldEqual, 354) 453 | So(reply.Lines(), ShouldResemble, []string{"354 End data with .\r\n"}) 454 | So(proto.State, ShouldEqual, DATA) 455 | reply = proto.ProcessData("Hi") 456 | So(reply, ShouldBeNil) 457 | So(proto.State, ShouldEqual, DATA) 458 | So(proto.Message.Data, ShouldEqual, "Hi\r\n") 459 | reply = proto.ProcessData("How are you?") 460 | So(reply, ShouldBeNil) 461 | So(proto.State, ShouldEqual, DATA) 462 | So(proto.Message.Data, ShouldEqual, "Hi\r\nHow are you?\r\n") 463 | reply = proto.ProcessData("\r\n.") 464 | So(reply, ShouldNotBeNil) 465 | So(reply.Status, ShouldEqual, 452) 466 | So(proto.State, ShouldEqual, MAIL) 467 | So(reply.Lines(), ShouldResemble, []string{"452 Unable to store message\r\n"}) 468 | So(handlerCalled, ShouldBeTrue) 469 | }) 470 | } 471 | 472 | func TestRSET(t *testing.T) { 473 | Convey("RSET should reset the state correctly", t, func() { 474 | proto := NewProtocol() 475 | proto.Start() 476 | proto.HELO("localhost") 477 | proto.Command(ParseCommand("MAIL FROM:")) 478 | proto.Command(ParseCommand("RCPT TO:")) 479 | So(proto.State, ShouldEqual, RCPT) 480 | So(proto.Message.From, ShouldEqual, "test") 481 | So(len(proto.Message.To), ShouldEqual, 1) 482 | So(proto.Message.To[0], ShouldEqual, "test") 483 | reply := proto.Command(ParseCommand("RSET")) 484 | So(reply, ShouldNotBeNil) 485 | So(reply.Status, ShouldEqual, 250) 486 | So(reply.Lines(), ShouldResemble, []string{"250 Ok\r\n"}) 487 | So(proto.State, ShouldEqual, MAIL) 488 | So(proto.Message.From, ShouldEqual, "") 489 | So(len(proto.Message.To), ShouldEqual, 0) 490 | }) 491 | } 492 | 493 | func TestNOOP(t *testing.T) { 494 | Convey("NOOP shouldn't modify the state", t, func() { 495 | proto := NewProtocol() 496 | proto.Start() 497 | proto.HELO("localhost") 498 | proto.Command(ParseCommand("MAIL FROM:")) 499 | proto.Command(ParseCommand("RCPT TO:")) 500 | So(proto.State, ShouldEqual, RCPT) 501 | So(proto.Message.From, ShouldEqual, "test") 502 | So(len(proto.Message.To), ShouldEqual, 1) 503 | So(proto.Message.To[0], ShouldEqual, "test") 504 | reply := proto.Command(ParseCommand("NOOP")) 505 | So(reply, ShouldNotBeNil) 506 | So(reply.Status, ShouldEqual, 250) 507 | So(reply.Lines(), ShouldResemble, []string{"250 Ok\r\n"}) 508 | So(proto.State, ShouldEqual, RCPT) 509 | So(proto.Message.From, ShouldEqual, "test") 510 | So(len(proto.Message.To), ShouldEqual, 1) 511 | So(proto.Message.To[0], ShouldEqual, "test") 512 | }) 513 | } 514 | 515 | func TestQUIT(t *testing.T) { 516 | Convey("QUIT should modify the state correctly", t, func() { 517 | proto := NewProtocol() 518 | proto.Start() 519 | reply := proto.Command(ParseCommand("QUIT")) 520 | So(proto.State, ShouldEqual, DONE) 521 | So(reply, ShouldNotBeNil) 522 | So(reply.Status, ShouldEqual, 221) 523 | So(reply.Lines(), ShouldResemble, []string{"221 Bye\r\n"}) 524 | }) 525 | } 526 | 527 | func TestParseMAIL(t *testing.T) { 528 | proto := NewProtocol() 529 | Convey("ParseMAIL should parse MAIL command arguments", t, func() { 530 | m, err := proto.ParseMAIL("FROM:") 531 | So(err, ShouldBeNil) 532 | So(m, ShouldEqual, "oink@mailhog.example") 533 | m, err = proto.ParseMAIL("FROM:") 534 | So(err, ShouldBeNil) 535 | So(m, ShouldEqual, "oink") 536 | }) 537 | Convey("ParseMAIL should return an error for invalid syntax", t, func() { 538 | m, err := proto.ParseMAIL("FROM:oink") 539 | So(err, ShouldNotBeNil) 540 | So(err.Error(), ShouldEqual, "Invalid syntax in MAIL command") 541 | So(m, ShouldEqual, "") 542 | }) 543 | Convey("ParseMAIL should be case-insensitive", t, func() { 544 | m, err := proto.ParseMAIL("FROM:") 545 | So(err, ShouldBeNil) 546 | So(m, ShouldEqual, "oink") 547 | m, err = proto.ParseMAIL("from:") 548 | So(err, ShouldBeNil) 549 | So(m, ShouldEqual, "oink@mailhog.example") 550 | m, err = proto.ParseMAIL("FrOm:") 551 | So(err, ShouldBeNil) 552 | So(m, ShouldEqual, "oink@oink.mailhog.example") 553 | }) 554 | Convey("ParseMAIL should support broken sender syntax", t, func() { 555 | m, err := proto.ParseMAIL("FROM: ") 556 | So(err, ShouldBeNil) 557 | So(m, ShouldEqual, "oink") 558 | m, err = proto.ParseMAIL("from: ") 559 | So(err, ShouldBeNil) 560 | So(m, ShouldEqual, "oink@mailhog.example") 561 | m, err = proto.ParseMAIL("FrOm: ") 562 | So(err, ShouldBeNil) 563 | So(m, ShouldEqual, "oink@oink.mailhog.example") 564 | }) 565 | Convey("Error should be returned via Command", t, func() { 566 | proto := NewProtocol() 567 | proto.Start() 568 | proto.Command(ParseCommand("HELO localhost")) 569 | So(proto.State, ShouldEqual, MAIL) 570 | reply := proto.Command(ParseCommand("MAIL oink")) 571 | So(reply, ShouldNotBeNil) 572 | So(reply.Status, ShouldEqual, 550) 573 | So(reply.Lines(), ShouldResemble, []string{"550 Invalid syntax in MAIL command\r\n"}) 574 | So(proto.State, ShouldEqual, MAIL) 575 | }) 576 | Convey("ValidateSenderHandler should be called", t, func() { 577 | proto := NewProtocol() 578 | handlerCalled := false 579 | proto.ValidateSenderHandler = func(sender string) bool { 580 | handlerCalled = true 581 | So(sender, ShouldEqual, "oink@mailhog.example") 582 | return true 583 | } 584 | proto.Start() 585 | proto.Command(ParseCommand("HELO localhost")) 586 | So(proto.State, ShouldEqual, MAIL) 587 | reply := proto.Command(ParseCommand("MAIL From:")) 588 | So(handlerCalled, ShouldBeTrue) 589 | So(reply, ShouldNotBeNil) 590 | So(reply.Status, ShouldEqual, 250) 591 | So(reply.Lines(), ShouldResemble, []string{"250 Sender oink@mailhog.example ok\r\n"}) 592 | So(proto.State, ShouldEqual, RCPT) 593 | }) 594 | Convey("ValidateSenderHandler errors should be returned", t, func() { 595 | proto := NewProtocol() 596 | handlerCalled := false 597 | proto.ValidateSenderHandler = func(sender string) bool { 598 | handlerCalled = true 599 | So(sender, ShouldEqual, "oink@mailhog.example") 600 | return false 601 | } 602 | proto.Start() 603 | proto.Command(ParseCommand("HELO localhost")) 604 | So(proto.State, ShouldEqual, MAIL) 605 | reply := proto.Command(ParseCommand("MAIL From:")) 606 | So(handlerCalled, ShouldBeTrue) 607 | So(reply, ShouldNotBeNil) 608 | So(reply.Status, ShouldEqual, 550) 609 | So(reply.Lines(), ShouldResemble, []string{"550 Invalid sender oink@mailhog.example\r\n"}) 610 | So(proto.State, ShouldEqual, MAIL) 611 | }) 612 | } 613 | 614 | func TestParseRCPT(t *testing.T) { 615 | proto := NewProtocol() 616 | Convey("ParseRCPT should parse RCPT command arguments", t, func() { 617 | m, err := proto.ParseRCPT("TO:") 618 | So(err, ShouldBeNil) 619 | So(m, ShouldEqual, "oink@mailhog.example") 620 | m, err = proto.ParseRCPT("TO:") 621 | So(err, ShouldBeNil) 622 | So(m, ShouldEqual, "oink") 623 | }) 624 | Convey("ParseRCPT should return an error for invalid syntax", t, func() { 625 | m, err := proto.ParseRCPT("TO:oink") 626 | So(err, ShouldNotBeNil) 627 | So(err.Error(), ShouldEqual, "Invalid syntax in RCPT command") 628 | So(m, ShouldEqual, "") 629 | }) 630 | Convey("ParseRCPT should be case-insensitive", t, func() { 631 | m, err := proto.ParseRCPT("TO:") 632 | So(err, ShouldBeNil) 633 | So(m, ShouldEqual, "oink") 634 | m, err = proto.ParseRCPT("to:") 635 | So(err, ShouldBeNil) 636 | So(m, ShouldEqual, "oink@mailhog.example") 637 | m, err = proto.ParseRCPT("To:") 638 | So(err, ShouldBeNil) 639 | So(m, ShouldEqual, "oink@oink.mailhog.example") 640 | }) 641 | Convey("ParseRCPT should support broken recipient syntax", t, func() { 642 | m, err := proto.ParseRCPT("TO: ") 643 | So(err, ShouldBeNil) 644 | So(m, ShouldEqual, "oink") 645 | m, err = proto.ParseRCPT("to: ") 646 | So(err, ShouldBeNil) 647 | So(m, ShouldEqual, "oink@mailhog.example") 648 | m, err = proto.ParseRCPT("To: ") 649 | So(err, ShouldBeNil) 650 | So(m, ShouldEqual, "oink@oink.mailhog.example") 651 | }) 652 | Convey("Error should be returned via Command", t, func() { 653 | proto := NewProtocol() 654 | proto.Start() 655 | proto.Command(ParseCommand("HELO localhost")) 656 | proto.Command(ParseCommand("MAIL FROM:")) 657 | So(proto.State, ShouldEqual, RCPT) 658 | reply := proto.Command(ParseCommand("RCPT oink")) 659 | So(reply, ShouldNotBeNil) 660 | So(reply.Status, ShouldEqual, 550) 661 | So(reply.Lines(), ShouldResemble, []string{"550 Invalid syntax in RCPT command\r\n"}) 662 | So(proto.State, ShouldEqual, RCPT) 663 | }) 664 | Convey("ValidateRecipientHandler should be called", t, func() { 665 | proto := NewProtocol() 666 | handlerCalled := false 667 | proto.ValidateRecipientHandler = func(recipient string) bool { 668 | handlerCalled = true 669 | So(recipient, ShouldEqual, "oink@mailhog.example") 670 | return true 671 | } 672 | proto.Start() 673 | proto.Command(ParseCommand("HELO localhost")) 674 | proto.Command(ParseCommand("MAIL FROM:")) 675 | So(proto.State, ShouldEqual, RCPT) 676 | reply := proto.Command(ParseCommand("RCPT To:")) 677 | So(handlerCalled, ShouldBeTrue) 678 | So(reply, ShouldNotBeNil) 679 | So(reply.Status, ShouldEqual, 250) 680 | So(reply.Lines(), ShouldResemble, []string{"250 Recipient oink@mailhog.example ok\r\n"}) 681 | So(proto.State, ShouldEqual, RCPT) 682 | }) 683 | Convey("ValidateRecipientHandler errors should be returned", t, func() { 684 | proto := NewProtocol() 685 | handlerCalled := false 686 | proto.ValidateRecipientHandler = func(recipient string) bool { 687 | handlerCalled = true 688 | So(recipient, ShouldEqual, "oink@mailhog.example") 689 | return false 690 | } 691 | proto.Start() 692 | proto.Command(ParseCommand("HELO localhost")) 693 | proto.Command(ParseCommand("MAIL FROM:")) 694 | So(proto.State, ShouldEqual, RCPT) 695 | reply := proto.Command(ParseCommand("RCPT To:")) 696 | So(handlerCalled, ShouldBeTrue) 697 | So(reply, ShouldNotBeNil) 698 | So(reply.Status, ShouldEqual, 550) 699 | So(reply.Lines(), ShouldResemble, []string{"550 Invalid recipient oink@mailhog.example\r\n"}) 700 | So(proto.State, ShouldEqual, RCPT) 701 | }) 702 | } 703 | 704 | func TestAuth(t *testing.T) { 705 | Convey("AUTH should be listed in EHLO response", t, func() { 706 | proto := NewProtocol() 707 | proto.Start() 708 | reply := proto.Command(ParseCommand("EHLO localhost")) 709 | So(reply, ShouldNotBeNil) 710 | So(reply.Status, ShouldEqual, 250) 711 | So(reply.Lines(), ShouldResemble, []string{"250-Hello localhost\r\n", "250 PIPELINING\r\n"}) 712 | }) 713 | 714 | Convey("Invalid mechanism should be rejected", t, func() { 715 | proto := NewProtocol() 716 | proto.Start() 717 | proto.Command(ParseCommand("EHLO localhost")) 718 | reply := proto.Command(ParseCommand("AUTH OINK")) 719 | So(reply, ShouldNotBeNil) 720 | So(reply.Status, ShouldEqual, 504) 721 | So(reply.Lines(), ShouldResemble, []string{"504 Unsupported authentication mechanism\r\n"}) 722 | }) 723 | } 724 | 725 | func TestAuthExternal(t *testing.T) { 726 | Convey("AUTH EXTERNAL should call ValidateAuthenticationHandler", t, func() { 727 | proto := NewProtocol() 728 | handlerCalled := false 729 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 730 | handlerCalled = true 731 | So(mechanism, ShouldEqual, "EXTERNAL") 732 | So(len(args), ShouldEqual, 1) 733 | So(args[0], ShouldEqual, "oink!") 734 | return nil, true 735 | } 736 | proto.Start() 737 | proto.Command(ParseCommand("EHLO localhost")) 738 | reply := proto.Command(ParseCommand("AUTH EXTERNAL oink!")) 739 | So(reply, ShouldNotBeNil) 740 | So(reply.Status, ShouldEqual, 235) 741 | So(reply.Lines(), ShouldResemble, []string{"235 Authentication successful\r\n"}) 742 | So(handlerCalled, ShouldBeTrue) 743 | }) 744 | 745 | Convey("AUTH EXTERNAL ValidateAuthenticationHandler errors should be returned", t, func() { 746 | proto := NewProtocol() 747 | handlerCalled := false 748 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 749 | handlerCalled = true 750 | return ReplyError(errors.New("OINK :(")), false 751 | } 752 | proto.Start() 753 | proto.Command(ParseCommand("EHLO localhost")) 754 | reply := proto.Command(ParseCommand("AUTH EXTERNAL oink!")) 755 | So(reply, ShouldNotBeNil) 756 | So(reply.Status, ShouldEqual, 550) 757 | So(reply.Lines(), ShouldResemble, []string{"550 OINK :(\r\n"}) 758 | So(handlerCalled, ShouldBeTrue) 759 | }) 760 | } 761 | 762 | func TestAuthPlain(t *testing.T) { 763 | Convey("Inline AUTH PLAIN should call ValidateAuthenticationHandler", t, func() { 764 | proto := NewProtocol() 765 | handlerCalled := false 766 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 767 | handlerCalled = true 768 | So(mechanism, ShouldEqual, "PLAIN") 769 | So(len(args), ShouldEqual, 2) 770 | So(args[0], ShouldEqual, "test@mailhog.example") 771 | So(args[1], ShouldEqual, "test") 772 | return nil, true 773 | } 774 | proto.Start() 775 | proto.Command(ParseCommand("EHLO localhost")) 776 | reply := proto.Command(ParseCommand("AUTH PLAIN AHRlc3RAbWFpbGhvZy5leGFtcGxlAHRlc3Q=")) 777 | So(reply, ShouldNotBeNil) 778 | So(reply.Status, ShouldEqual, 235) 779 | So(reply.Lines(), ShouldResemble, []string{"235 Authentication successful\r\n"}) 780 | So(handlerCalled, ShouldBeTrue) 781 | }) 782 | 783 | Convey("Inline AUTH PLAIN ValidateAuthenticationHandler errors should be returned", t, func() { 784 | proto := NewProtocol() 785 | handlerCalled := false 786 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 787 | handlerCalled = true 788 | return ReplyError(errors.New("OINK :(")), false 789 | } 790 | proto.Start() 791 | proto.Command(ParseCommand("EHLO localhost")) 792 | reply := proto.Command(ParseCommand("AUTH PLAIN oink!")) 793 | So(reply, ShouldNotBeNil) 794 | So(reply.Status, ShouldEqual, 550) 795 | So(reply.Lines(), ShouldResemble, []string{"550 Badly formed parameter\r\n"}) 796 | So(handlerCalled, ShouldBeFalse) 797 | }) 798 | 799 | Convey("Two part AUTH PLAIN should call ValidateAuthenticationHandler", t, func() { 800 | proto := NewProtocol() 801 | handlerCalled := false 802 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 803 | handlerCalled = true 804 | So(mechanism, ShouldEqual, "PLAIN") 805 | So(len(args), ShouldEqual, 2) 806 | So(args[0], ShouldEqual, "test@mailhog.example") 807 | So(args[1], ShouldEqual, "test") 808 | return nil, true 809 | } 810 | proto.Start() 811 | proto.Command(ParseCommand("EHLO localhost")) 812 | reply := proto.Command(ParseCommand("AUTH PLAIN")) 813 | So(reply, ShouldNotBeNil) 814 | So(reply.Status, ShouldEqual, 334) 815 | So(reply.Lines(), ShouldResemble, []string{"334 \r\n"}) 816 | 817 | _, reply = proto.Parse("AHRlc3RAbWFpbGhvZy5leGFtcGxlAHRlc3Q=\r\n") 818 | So(reply, ShouldNotBeNil) 819 | So(reply.Status, ShouldEqual, 235) 820 | So(reply.Lines(), ShouldResemble, []string{"235 Authentication successful\r\n"}) 821 | So(handlerCalled, ShouldBeTrue) 822 | }) 823 | 824 | Convey("Two part AUTH PLAIN ValidateAuthenticationHandler errors should be returned", t, func() { 825 | proto := NewProtocol() 826 | handlerCalled := false 827 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 828 | handlerCalled = true 829 | return ReplyError(errors.New("OINK :(")), false 830 | } 831 | proto.Start() 832 | proto.Command(ParseCommand("EHLO localhost")) 833 | reply := proto.Command(ParseCommand("AUTH PLAIN")) 834 | So(reply, ShouldNotBeNil) 835 | So(reply.Status, ShouldEqual, 334) 836 | So(reply.Lines(), ShouldResemble, []string{"334 \r\n"}) 837 | 838 | _, reply = proto.Parse("AHRlc3RAbWFpbGhvZy5leGFtcGxlAHRlc3Q=\r\n") 839 | So(reply, ShouldNotBeNil) 840 | So(reply.Status, ShouldEqual, 550) 841 | So(reply.Lines(), ShouldResemble, []string{"550 OINK :(\r\n"}) 842 | So(handlerCalled, ShouldBeTrue) 843 | }) 844 | } 845 | 846 | func TestAuthCramMD5(t *testing.T) { 847 | Convey("Two part AUTH CRAM-MD5 should call ValidateAuthenticationHandler", t, func() { 848 | proto := NewProtocol() 849 | handlerCalled := false 850 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 851 | handlerCalled = true 852 | So(mechanism, ShouldEqual, "CRAM-MD5") 853 | So(len(args), ShouldEqual, 1) 854 | So(args[0], ShouldEqual, "oink!") 855 | return nil, true 856 | } 857 | proto.Start() 858 | proto.Command(ParseCommand("EHLO localhost")) 859 | reply := proto.Command(ParseCommand("AUTH CRAM-MD5")) 860 | So(reply, ShouldNotBeNil) 861 | So(reply.Status, ShouldEqual, 334) 862 | So(reply.Lines(), ShouldResemble, []string{"334 PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4=\r\n"}) 863 | 864 | _, reply = proto.Parse("oink!\r\n") 865 | So(reply, ShouldNotBeNil) 866 | So(reply.Status, ShouldEqual, 235) 867 | So(reply.Lines(), ShouldResemble, []string{"235 Authentication successful\r\n"}) 868 | So(handlerCalled, ShouldBeTrue) 869 | }) 870 | 871 | Convey("Two part AUTH CRAM-MD5 ValidateAuthenticationHandler errors should be returned", t, func() { 872 | proto := NewProtocol() 873 | handlerCalled := false 874 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 875 | handlerCalled = true 876 | return ReplyError(errors.New("OINK :(")), false 877 | } 878 | proto.Start() 879 | proto.Command(ParseCommand("EHLO localhost")) 880 | reply := proto.Command(ParseCommand("AUTH CRAM-MD5")) 881 | So(reply, ShouldNotBeNil) 882 | So(reply.Status, ShouldEqual, 334) 883 | So(reply.Lines(), ShouldResemble, []string{"334 PDQxOTI5NDIzNDEuMTI4Mjg0NzJAc291cmNlZm91ci5hbmRyZXcuY211LmVkdT4=\r\n"}) 884 | 885 | _, reply = proto.Parse("oink!\r\n") 886 | So(reply, ShouldNotBeNil) 887 | So(reply.Status, ShouldEqual, 550) 888 | So(reply.Lines(), ShouldResemble, []string{"550 OINK :(\r\n"}) 889 | So(handlerCalled, ShouldBeTrue) 890 | }) 891 | } 892 | 893 | func TestAuthLogin(t *testing.T) { 894 | Convey("AUTH LOGIN should call ValidateAuthenticationHandler", t, func() { 895 | proto := NewProtocol() 896 | handlerCalled := false 897 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 898 | handlerCalled = true 899 | So(mechanism, ShouldEqual, "LOGIN") 900 | So(len(args), ShouldEqual, 2) 901 | So(args[0], ShouldEqual, "username!") 902 | So(args[1], ShouldEqual, "password!") 903 | return nil, true 904 | } 905 | proto.Start() 906 | proto.Command(ParseCommand("EHLO localhost")) 907 | reply := proto.Command(ParseCommand("AUTH LOGIN")) 908 | So(reply, ShouldNotBeNil) 909 | So(reply.Status, ShouldEqual, 334) 910 | So(reply.Lines(), ShouldResemble, []string{"334 VXNlcm5hbWU6\r\n"}) 911 | 912 | _, reply = proto.Parse("username!\r\n") 913 | So(reply, ShouldNotBeNil) 914 | So(reply.Status, ShouldEqual, 334) 915 | So(reply.Lines(), ShouldResemble, []string{"334 UGFzc3dvcmQ6\r\n"}) 916 | 917 | _, reply = proto.Parse("password!\r\n") 918 | So(reply, ShouldNotBeNil) 919 | So(reply.Status, ShouldEqual, 235) 920 | So(reply.Lines(), ShouldResemble, []string{"235 Authentication successful\r\n"}) 921 | So(handlerCalled, ShouldBeTrue) 922 | }) 923 | 924 | Convey("AUTH LOGIN ValidateAuthenticationHandler errors should be returned", t, func() { 925 | proto := NewProtocol() 926 | handlerCalled := false 927 | proto.ValidateAuthenticationHandler = func(mechanism string, args ...string) (*Reply, bool) { 928 | handlerCalled = true 929 | return ReplyError(errors.New("OINK :(")), false 930 | } 931 | proto.Start() 932 | proto.Command(ParseCommand("EHLO localhost")) 933 | reply := proto.Command(ParseCommand("AUTH LOGIN")) 934 | So(reply, ShouldNotBeNil) 935 | So(reply.Status, ShouldEqual, 334) 936 | So(reply.Lines(), ShouldResemble, []string{"334 VXNlcm5hbWU6\r\n"}) 937 | 938 | _, reply = proto.Parse("username!\r\n") 939 | So(reply, ShouldNotBeNil) 940 | So(reply.Status, ShouldEqual, 334) 941 | So(reply.Lines(), ShouldResemble, []string{"334 UGFzc3dvcmQ6\r\n"}) 942 | 943 | _, reply = proto.Parse("password!\r\n") 944 | So(reply, ShouldNotBeNil) 945 | So(reply.Status, ShouldEqual, 550) 946 | So(reply.Lines(), ShouldResemble, []string{"550 OINK :(\r\n"}) 947 | So(handlerCalled, ShouldBeTrue) 948 | }) 949 | } 950 | -------------------------------------------------------------------------------- /reply.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import "strconv" 4 | 5 | // http://www.rfc-editor.org/rfc/rfc5321.txt 6 | 7 | // Reply is a struct representing an SMTP reply (status code + lines) 8 | type Reply struct { 9 | Status int 10 | lines []string 11 | Done func() 12 | } 13 | 14 | // Lines returns the formatted SMTP reply 15 | func (r Reply) Lines() []string { 16 | var lines []string 17 | 18 | if len(r.lines) == 0 { 19 | l := strconv.Itoa(r.Status) 20 | lines = append(lines, l+"\n") 21 | return lines 22 | } 23 | 24 | for i, line := range r.lines { 25 | l := "" 26 | if i == len(r.lines)-1 { 27 | l = strconv.Itoa(r.Status) + " " + line + "\r\n" 28 | } else { 29 | l = strconv.Itoa(r.Status) + "-" + line + "\r\n" 30 | } 31 | lines = append(lines, l) 32 | } 33 | 34 | return lines 35 | } 36 | 37 | // ReplyIdent creates a 220 welcome reply 38 | func ReplyIdent(ident string) *Reply { return &Reply{220, []string{ident}, nil} } 39 | 40 | // ReplyReadyToStartTLS creates a 220 ready to start TLS reply 41 | func ReplyReadyToStartTLS(callback func()) *Reply { 42 | return &Reply{220, []string{"Ready to start TLS"}, callback} 43 | } 44 | 45 | // ReplyBye creates a 221 Bye reply 46 | func ReplyBye() *Reply { return &Reply{221, []string{"Bye"}, nil} } 47 | 48 | // ReplyAuthOk creates a 235 authentication successful reply 49 | func ReplyAuthOk() *Reply { return &Reply{235, []string{"Authentication successful"}, nil} } 50 | 51 | // ReplyOk creates a 250 Ok reply 52 | func ReplyOk(message ...string) *Reply { 53 | if len(message) == 0 { 54 | message = []string{"Ok"} 55 | } 56 | return &Reply{250, message, nil} 57 | } 58 | 59 | // ReplySenderOk creates a 250 Sender ok reply 60 | func ReplySenderOk(sender string) *Reply { 61 | return &Reply{250, []string{"Sender " + sender + " ok"}, nil} 62 | } 63 | 64 | // ReplyRecipientOk creates a 250 Sender ok reply 65 | func ReplyRecipientOk(recipient string) *Reply { 66 | return &Reply{250, []string{"Recipient " + recipient + " ok"}, nil} 67 | } 68 | 69 | // ReplyAuthResponse creates a 334 authentication reply 70 | func ReplyAuthResponse(response string) *Reply { return &Reply{334, []string{response}, nil} } 71 | 72 | // ReplyDataResponse creates a 354 data reply 73 | func ReplyDataResponse() *Reply { return &Reply{354, []string{"End data with ."}, nil} } 74 | 75 | // ReplyStorageFailed creates a 452 error reply 76 | func ReplyStorageFailed(reason string) *Reply { return &Reply{452, []string{reason}, nil} } 77 | 78 | // ReplyUnrecognisedCommand creates a 500 Unrecognised command reply 79 | func ReplyUnrecognisedCommand() *Reply { return &Reply{500, []string{"Unrecognised command"}, nil} } 80 | 81 | // ReplyLineTooLong creates a 500 Line too long reply 82 | func ReplyLineTooLong() *Reply { return &Reply{500, []string{"Line too long"}, nil} } 83 | 84 | // ReplySyntaxError creates a 501 Syntax error reply 85 | func ReplySyntaxError(response string) *Reply { 86 | if len(response) > 0 { 87 | response = " (" + response + ")" 88 | } 89 | return &Reply{501, []string{"Syntax error" + response}, nil} 90 | } 91 | 92 | // ReplyUnsupportedAuth creates a 504 unsupported authentication reply 93 | func ReplyUnsupportedAuth() *Reply { 94 | return &Reply{504, []string{"Unsupported authentication mechanism"}, nil} 95 | } 96 | 97 | // ReplyMustIssueSTARTTLSFirst creates a 530 reply for RFC3207 98 | func ReplyMustIssueSTARTTLSFirst() *Reply { 99 | return &Reply{530, []string{"Must issue a STARTTLS command first"}, nil} 100 | } 101 | 102 | // ReplyInvalidAuth creates a 535 error reply 103 | func ReplyInvalidAuth() *Reply { 104 | return &Reply{535, []string{"Authentication credentials invalid"}, nil} 105 | } 106 | 107 | // ReplyError creates a 500 error reply 108 | func ReplyError(err error) *Reply { return &Reply{550, []string{err.Error()}, nil} } 109 | 110 | // ReplyTooManyRecipients creates a 552 too many recipients reply 111 | func ReplyTooManyRecipients() *Reply { return &Reply{552, []string{"Too many recipients"}, nil} } 112 | -------------------------------------------------------------------------------- /reply_test.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | // http://www.rfc-editor.org/rfc/rfc5321.txt 4 | 5 | import ( 6 | "testing" 7 | 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | func TestReply(t *testing.T) { 12 | Convey("Reply creates properly formatted responses", t, func() { 13 | r := &Reply{200, []string{}, nil} 14 | l := r.Lines() 15 | So(l[0], ShouldEqual, "200\n") 16 | 17 | r = &Reply{200, []string{"Ok"}, nil} 18 | l = r.Lines() 19 | So(l[0], ShouldEqual, "200 Ok\r\n") 20 | 21 | r = &Reply{200, []string{"Ok", "Still ok!"}, nil} 22 | l = r.Lines() 23 | So(l[0], ShouldEqual, "200-Ok\r\n") 24 | So(l[1], ShouldEqual, "200 Still ok!\r\n") 25 | 26 | r = &Reply{200, []string{"Ok", "Still ok!", "OINK!"}, nil} 27 | l = r.Lines() 28 | So(l[0], ShouldEqual, "200-Ok\r\n") 29 | So(l[1], ShouldEqual, "200-Still ok!\r\n") 30 | So(l[2], ShouldEqual, "200 OINK!\r\n") 31 | }) 32 | } 33 | 34 | func TestBuiltInReplies(t *testing.T) { 35 | Convey("ReplyIdent is correct", t, func() { 36 | r := ReplyIdent("oink") 37 | So(r.Status, ShouldEqual, 220) 38 | So(len(r.lines), ShouldEqual, 1) 39 | So(r.lines[0], ShouldEqual, "oink") 40 | }) 41 | 42 | Convey("ReplyBye is correct", t, func() { 43 | r := ReplyBye() 44 | So(r.Status, ShouldEqual, 221) 45 | So(len(r.lines), ShouldEqual, 1) 46 | So(r.lines[0], ShouldEqual, "Bye") 47 | }) 48 | 49 | Convey("ReplyAuthOk is correct", t, func() { 50 | r := ReplyAuthOk() 51 | So(r.Status, ShouldEqual, 235) 52 | So(len(r.lines), ShouldEqual, 1) 53 | So(r.lines[0], ShouldEqual, "Authentication successful") 54 | }) 55 | 56 | Convey("ReplyOk is correct", t, func() { 57 | r := ReplyOk() 58 | So(r.Status, ShouldEqual, 250) 59 | So(len(r.lines), ShouldEqual, 1) 60 | So(r.lines[0], ShouldEqual, "Ok") 61 | 62 | r = ReplyOk("oink") 63 | So(r.Status, ShouldEqual, 250) 64 | So(len(r.lines), ShouldEqual, 1) 65 | So(r.lines[0], ShouldEqual, "oink") 66 | 67 | r = ReplyOk("mailhog", "OINK!") 68 | So(r.Status, ShouldEqual, 250) 69 | So(len(r.lines), ShouldEqual, 2) 70 | So(r.lines[0], ShouldEqual, "mailhog") 71 | So(r.lines[1], ShouldEqual, "OINK!") 72 | }) 73 | 74 | Convey("ReplySenderOk is correct", t, func() { 75 | r := ReplySenderOk("test") 76 | So(r.Status, ShouldEqual, 250) 77 | So(len(r.lines), ShouldEqual, 1) 78 | So(r.lines[0], ShouldEqual, "Sender test ok") 79 | }) 80 | 81 | Convey("ReplyRecipientOk is correct", t, func() { 82 | r := ReplyRecipientOk("test") 83 | So(r.Status, ShouldEqual, 250) 84 | So(len(r.lines), ShouldEqual, 1) 85 | So(r.lines[0], ShouldEqual, "Recipient test ok") 86 | }) 87 | 88 | Convey("ReplyAuthResponse is correct", t, func() { 89 | r := ReplyAuthResponse("test") 90 | So(r.Status, ShouldEqual, 334) 91 | So(len(r.lines), ShouldEqual, 1) 92 | So(r.lines[0], ShouldEqual, "test") 93 | }) 94 | 95 | Convey("ReplyDataResponse is correct", t, func() { 96 | r := ReplyDataResponse() 97 | So(r.Status, ShouldEqual, 354) 98 | So(len(r.lines), ShouldEqual, 1) 99 | So(r.lines[0], ShouldEqual, "End data with .") 100 | }) 101 | 102 | Convey("ReplyStorageFailed is correct", t, func() { 103 | r := ReplyStorageFailed("test") 104 | So(r.Status, ShouldEqual, 452) 105 | So(len(r.lines), ShouldEqual, 1) 106 | So(r.lines[0], ShouldEqual, "test") 107 | }) 108 | 109 | Convey("ReplyUnrecognisedCommand is correct", t, func() { 110 | r := ReplyUnrecognisedCommand() 111 | So(r.Status, ShouldEqual, 500) 112 | So(len(r.lines), ShouldEqual, 1) 113 | So(r.lines[0], ShouldEqual, "Unrecognised command") 114 | }) 115 | 116 | Convey("ReplyUnsupportedAuth is correct", t, func() { 117 | r := ReplyUnsupportedAuth() 118 | So(r.Status, ShouldEqual, 504) 119 | So(len(r.lines), ShouldEqual, 1) 120 | So(r.lines[0], ShouldEqual, "Unsupported authentication mechanism") 121 | }) 122 | } 123 | -------------------------------------------------------------------------------- /state.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | // State represents the state of an SMTP conversation 4 | type State int 5 | 6 | // SMTP message conversation states 7 | const ( 8 | INVALID = State(-1) 9 | ESTABLISH = State(iota) 10 | AUTHPLAIN 11 | AUTHLOGIN 12 | AUTHLOGIN2 13 | AUTHCRAMMD5 14 | MAIL 15 | RCPT 16 | DATA 17 | DONE 18 | ) 19 | 20 | // StateMap provides string representations of SMTP conversation states 21 | var StateMap = map[State]string{ 22 | INVALID: "INVALID", 23 | ESTABLISH: "ESTABLISH", 24 | AUTHPLAIN: "AUTHPLAIN", 25 | AUTHLOGIN: "AUTHLOGIN", 26 | AUTHLOGIN2: "AUTHLOGIN2", 27 | AUTHCRAMMD5: "AUTHCRAMMD5", 28 | MAIL: "MAIL", 29 | RCPT: "RCPT", 30 | DATA: "DATA", 31 | DONE: "DONE", 32 | } 33 | --------------------------------------------------------------------------------