├── .github └── workflows │ └── experiment.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── address.go ├── auth.go ├── command.go ├── command_test.go ├── envelope.go ├── envelope_test.go ├── examples └── mamail.go ├── go.mod ├── go.sum ├── limits.go ├── response.go ├── server.go ├── server_test.go ├── session.go ├── session_test.go └── utils.go /.github/workflows/experiment.yml: -------------------------------------------------------------------------------- 1 | name: Experiment 2 | on: [push] 3 | jobs: 4 | first_job: 5 | name: My Job Name 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Experiment2 9 | uses: matoous/golangci-lint-action@v0.0.2 10 | - name: Experiment 11 | uses: docker://matousdz/golangci-lint-action:v0.0.2 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | certs/ -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2018 Matouš Dzivjak (matousdzivjak@gmail.com) 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GOSMTP 2 | 3 | GOSMTP is golang implementation of full-featured, RFC standard compliant and lightweight SMTP server. 4 | 5 | ## Mail Transport Agent 6 | 7 | Accepts and handles incoming mail. 8 | Supported authentication methods: 9 | 10 | * PLAIN 11 | * LOGIN 12 | 13 | ## Setup 14 | 15 | ### Download 16 | 17 | 1. Download 18 | 19 | 2. Clone 20 | 21 | ### Config 22 | 23 | Minimal configuration you will need might look like this: 24 | 25 | ```toml 26 | me: example.com 27 | ``` 28 | 29 | With this minimal configuration you will be able to run your email server at given 30 | domain. This server will use defaults for all you haven't specified. 31 | 32 | ### Run 33 | 34 | To start the server just simply run 35 | 36 | ```go 37 | ./mamail 38 | ``` 39 | 40 | ## About 41 | 42 | ### TLS 43 | 44 | GOSMTP aims to be secure and modern. TLS should be always allowed as specified in 45 | 46 | > As a result, clients and servers SHOULD implement both STARTTLS on 47 | > port 587 and Implicit TLS on port 465 for this transition period. 48 | > Note that there is no significant difference between the security 49 | > properties of STARTTLS on port 587 and Implicit TLS on port 465 if 50 | > the implementations are correct and if both the client and the server 51 | > are configured to require successful negotiation of TLS prior to 52 | > Message Submission. 53 | 54 | Fot testing purpouses one can generate certificate via [https://github.com/deckarep/EasyCert](https://github.com/deckarep/EasyCert) 55 | 56 | ### DNS 57 | 58 | #### MX Records 59 | 60 | It is recommended that MSPs advertise MX records for the handling of 61 | inbound mail (instead of relying entirely on A or AAAA records) and 62 | that those MX records be signed using DNSSEC [RFC4033]. This is 63 | mentioned here only for completeness, as the handling of inbound mail 64 | is out of scope for this document. 65 | 66 | #### SRV Records 67 | 68 | MSPs SHOULD advertise SRV records to aid MUAs in determining the 69 | proper configuration of servers, per the instructions in [RFC6186]. 70 | 71 | MSPs SHOULD advertise servers that support Implicit TLS in preference 72 | to servers that support cleartext and/or STARTTLS operation. 73 | 74 | #### DNSSEC 75 | 76 | All DNS records advertised by an MSP as a means of aiding clients in 77 | communicating with the MSP's servers SHOULD be signed using DNSSEC if 78 | and when the parent DNS zone supports doing so. 79 | 80 | #### TLSA Records 81 | 82 | MSPs SHOULD advertise TLSA records to provide an additional trust 83 | anchor for public keys used in TLS server certificates. However, 84 | TLSA records MUST NOT be advertised unless they are signed using 85 | DNSSEC. 86 | 87 | ### SPF 88 | 89 | Publishing Authorization 90 | 91 | An SPF-compliant domain MUST publish a valid SPF record as described 92 | in Section 3. This record authorizes the use of the domain name in 93 | the "HELO" and "MAIL FROM" identities by the MTAs it specifies. 94 | 95 | If domain owners choose to publish SPF records, it is RECOMMENDED 96 | that they end in "-all", or redirect to other records that do, so 97 | that a definitive determination of authorization can be made. 98 | 99 | Domain holders may publish SPF records that explicitly authorize no 100 | hosts if mail should never originate using that domain. 101 | 102 | When changing SPF records, care must be taken to ensure that there is 103 | a transition period so that the old policy remains valid until all 104 | legitimate E-Mail has been checked. 105 | 106 | ## Contributing 107 | 108 | ### Goals 109 | 110 | * full-featured, production ready MTA agent 111 | * easily extensible -------------------------------------------------------------------------------- /address.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net" 7 | "net/mail" 8 | "strings" 9 | ) 10 | 11 | func parseAddress(src string) (*mail.Address, error) { 12 | addr, err := mail.ParseAddress(src) 13 | if err != nil { 14 | return nil, fmt.Errorf("malformed e-mail address: %s", src) 15 | } 16 | return addr, nil 17 | } 18 | 19 | func hostname(addr *mail.Address) string { 20 | // if the mail address didn't have @ it would be caught by parseAddress 21 | return string(bytes.Split([]byte(addr.Address), []byte{'@'})[1]) 22 | } 23 | 24 | // IsFQN checks if email host is full qualified name (MX or A record) 25 | func IsFQN(addr *mail.Address) string { 26 | ok, err := fqn(hostname(addr)) 27 | if err != nil { 28 | return Codes.ErrorUnableToResolveHost 29 | } else if !ok { 30 | return Codes.FailUnqalifiedHostName 31 | } 32 | return "" 33 | } 34 | 35 | // isFQN checks if domain is FQN (MX or A record) and caches the result 36 | // TODO refactor 37 | func fqn(host string) (bool, error) { 38 | _, err := net.LookupMX(host) 39 | if err != nil { 40 | if strings.HasSuffix(err.Error(), "no such host") { 41 | _, err = net.LookupHost(host) 42 | if err != nil { 43 | return false, err 44 | } 45 | } 46 | } 47 | return true, nil 48 | } 49 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "encoding/base64" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | /* 10 | SupportedAuthMechanisms is array of string describing currently supported/implemented 11 | authentication mechanisms 12 | */ 13 | var SupportedAuthMechanisms = []string{"LOGIN", "PLAIN"} 14 | 15 | func (s *session) handleLoginAuth(cmd *command) { 16 | args := cmd.arguments 17 | var username string 18 | var password []byte 19 | if len(args) == 1 { 20 | line, err := s.ReadLine() 21 | if err != nil { 22 | s.state = sessionStateAborted 23 | return 24 | } 25 | cmd := strings.TrimRightFunc(line, unicode.IsSpace) 26 | s.log.Printf("INFO: received AUTH cmd: '%s'", cmd) 27 | data, err := base64.StdEncoding.DecodeString(cmd) 28 | if err != nil { 29 | s.Out("501 malformed auth input") 30 | s.badCommandsCount++ 31 | return 32 | } 33 | username = string(data) 34 | s.Out("334 UGFzc3dvcmQ6") 35 | line, err = s.ReadLine() 36 | if err != nil { 37 | s.state = sessionStateAborted 38 | return 39 | } 40 | cmd = strings.TrimRightFunc(line, unicode.IsSpace) 41 | s.log.Printf("INFO: received second AUTH cmd: '%s'", cmd) 42 | password, err = base64.StdEncoding.DecodeString(cmd) 43 | if err != nil { 44 | s.Out("501 malformed auth input") 45 | return 46 | } 47 | } else if len(args) == 2 { 48 | data, err := base64.StdEncoding.DecodeString(args[1]) 49 | if err != nil { 50 | s.Out("501 malformed auth input") 51 | return 52 | } 53 | username = string(data) 54 | s.Out("334 UGFzc3dvcmQ6") 55 | line, err := s.ReadLine() 56 | if err != nil { 57 | s.Out(Codes.FailLineTooLong) 58 | s.badCommandsCount++ 59 | return 60 | } 61 | cmd := strings.TrimRightFunc(line, unicode.IsSpace) 62 | s.log.Printf("INFO: received second AUTH cmd: '%s'", cmd) 63 | password, err = base64.StdEncoding.DecodeString(cmd) 64 | if err != nil { 65 | s.Out("501 malformed auth input") 66 | s.state = sessionStateAborted 67 | return 68 | } 69 | } 70 | 71 | // check login 72 | s.peer.Username = username 73 | ok, err := s.srv.Authenticator(s.peer, password) 74 | if err != nil { 75 | s.Out(Codes.ErrorAuth) 76 | s.state = sessionStateAborted 77 | return 78 | } 79 | if !ok { 80 | s.Out(Codes.FailAuthentication) 81 | return 82 | } 83 | 84 | // login succeeded 85 | s.peer.Authenticated = true 86 | s.Out(Codes.SuccessAuthentication) 87 | return 88 | } 89 | 90 | func (s *session) handlePlainAuth(cmd *command) { 91 | args := cmd.arguments 92 | var authData []byte 93 | var err error 94 | // check that PLAIN auth input is of valid form 95 | if len(args) == 1 { 96 | s.Out("334") 97 | line, err := s.ReadLine() 98 | if err != nil { 99 | s.state = sessionStateAborted 100 | return 101 | } 102 | cmd, err := parseCommand(strings.TrimRightFunc(line, unicode.IsSpace)) 103 | s.log.Printf("INFO: received AUTH cmd: '%s'", cmd) 104 | if err != nil { 105 | s.Out(Codes.FailUnrecognizedCmd) 106 | s.badCommandsCount++ 107 | return 108 | } 109 | authData, err = base64.StdEncoding.DecodeString(cmd.arguments[0]) 110 | if err != nil { 111 | s.Out("501 malformed auth input") 112 | s.badCommandsCount++ 113 | return 114 | } 115 | } else if len(args) == 2 { 116 | authData, err = base64.StdEncoding.DecodeString(args[1]) 117 | if err != nil { 118 | s.Out("501 malformed auth input") 119 | s.badCommandsCount++ 120 | return 121 | } 122 | } 123 | 124 | // split 125 | t := make([][]byte, 3) 126 | i := 0 127 | for _, b := range authData { 128 | if b == 0 { 129 | i++ 130 | continue 131 | } 132 | t[i] = append(t[i], b) 133 | } 134 | //authId := string(t[0]) 135 | authLogin := string(t[1]) 136 | authPasswd := t[2] 137 | 138 | // check login 139 | s.peer.Username = authLogin 140 | ok, err := s.srv.Authenticator(s.peer, authPasswd) 141 | if err != nil { 142 | s.Out(Codes.ErrorAuth) 143 | s.state = sessionStateAborted 144 | return 145 | } 146 | if !ok { 147 | s.Out(Codes.FailAuthentication) 148 | return 149 | } 150 | // login succeeded 151 | s.peer.Authenticated = true 152 | s.Out(Codes.SuccessAuthentication) 153 | return 154 | } 155 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | type command struct { 9 | commandCode int 10 | verb string 11 | data string 12 | arguments []string 13 | } 14 | 15 | const ( 16 | heloCmd = iota 17 | ehloCmd 18 | quitCmd 19 | rsetCmd 20 | noopCmd 21 | mailCmd 22 | rcptCmd 23 | dataCmd 24 | starttlsCmd 25 | vrfyCmd 26 | expnCmd 27 | helpCmd 28 | authCmd 29 | bdatCmd 30 | ) 31 | 32 | /* 33 | isall7bit returns true if the argument is all 7-bit ASCII. This is what all SMTP 34 | commands are supposed to be, and later things are going to screw up if 35 | some joker hands us UTF-8 or any other equivalent. 36 | */ 37 | func isall7bit(b []byte) bool { 38 | for _, c := range b { 39 | if c > 127 { 40 | return false 41 | } 42 | } 43 | return true 44 | } 45 | 46 | /* 47 | parseCommand parses command from string or returns error if it is not possible 48 | or the command doesn't exist 49 | */ 50 | func parseCommand(line string) (*command, error) { 51 | parts := strings.SplitN(line, " ", 2) 52 | 53 | if len(parts) == 0 { 54 | return nil, errors.New("command empty") 55 | } 56 | 57 | // Check that command doesn't contain UTF-8 and other smelly stuff 58 | if !isall7bit([]byte(parts[0])) { 59 | return nil, errors.New("command contains non 7-bit ASCII") 60 | } 61 | 62 | // Search if the command is in our command list 63 | var cmdCode = -1 64 | switch strings.ToUpper(parts[0]) { 65 | case "HELO": 66 | cmdCode = heloCmd 67 | case "EHLO": 68 | cmdCode = ehloCmd 69 | case "QUIT": 70 | cmdCode = quitCmd 71 | case "RSET": 72 | cmdCode = rsetCmd 73 | case "NOOP": 74 | cmdCode = noopCmd 75 | case "MAIL": 76 | cmdCode = mailCmd 77 | case "RCPT": 78 | cmdCode = rcptCmd 79 | case "DATA": 80 | cmdCode = dataCmd 81 | case "STARTTLS": 82 | cmdCode = starttlsCmd 83 | case "VRFY": 84 | cmdCode = vrfyCmd 85 | case "EXPN": 86 | cmdCode = expnCmd 87 | case "HELP": 88 | cmdCode = helpCmd 89 | case "AUTH": 90 | cmdCode = authCmd 91 | case "BDAT": 92 | cmdCode = bdatCmd 93 | default: 94 | return nil, errors.New("unrecognized command") 95 | } 96 | 97 | cmd := &command{ 98 | cmdCode, 99 | parts[0], 100 | line, 101 | nil, 102 | } 103 | 104 | if len(parts) > 1 { 105 | cmd.arguments = strings.Split(parts[1], " ") 106 | } 107 | 108 | // Check for verbs defined not to have an argument 109 | // (RFC 5321 s4.1.1) 110 | switch cmd.commandCode { 111 | case rsetCmd, dataCmd, quitCmd: 112 | if len(cmd.arguments) != 0 { 113 | return nil, errors.New("unexpected argument") 114 | } 115 | } 116 | return cmd, nil 117 | } 118 | 119 | /* 120 | String returns back the original line with command as a string 121 | */ 122 | func (cmd *command) String() string { 123 | return cmd.data 124 | } 125 | -------------------------------------------------------------------------------- /command_test.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCommand_Args(t *testing.T) { 10 | cmd, err := parseCommand("MAIL") 11 | if err != nil { 12 | panic(err) 13 | } 14 | assert.Nil(t, cmd.arguments, 15 | "'MAIL' command args should be empty") 16 | 17 | cmd, err = parseCommand("MAIL arg1") 18 | if err != nil { 19 | panic(err) 20 | } 21 | assert.Equal(t, []string{"arg1"}, cmd.arguments, 22 | "'MAIL arg1' command arguments should contain one argument 'arg1'") 23 | 24 | cmd, err = parseCommand("MAIL arg1 arg2 arg3") 25 | if err != nil { 26 | panic(err) 27 | } 28 | assert.Equal(t, []string{"arg1", "arg2", "arg3"}, cmd.arguments, 29 | "'MAIL arg1 arg2 arg3' command arguments should contain three arguments 'arg1', 'arg2' and 'arg3'") 30 | } 31 | 32 | func TestParseCommand(t *testing.T) { 33 | // non 7-bit ASCII command 34 | cmd, err := parseCommand("čšěř test@test.te -d") 35 | assert.Nil(t, cmd, "parsing non 7-bit ASCII command 'čšěř' should return nil command") 36 | assert.Error(t, err, "parsing non 7-bit ASCII command 'čšěř' should return error") 37 | 38 | // empty command 39 | cmd, err = parseCommand("") 40 | assert.Nil(t, cmd, "parsing empty command should return nil command") 41 | assert.Error(t, err, "parsing empty command should return error") 42 | 43 | // valid command 44 | cmd, err = parseCommand("MAIL FROM: 8BITMIME") 45 | assert.NoError(t, err, "parsing 'MAIL' command shouldn't return error") 46 | assert.Equal(t, mailCmd, cmd.commandCode, "parsing 'MAIL' command should parse correct command code 'mailCmd'") 47 | assert.Equal(t, "MAIL", cmd.verb, "parsing 'MAIL' command should parse correct verb 'MAIL'") 48 | assert.Equal(t, "MAIL FROM: 8BITMIME", cmd.data, "parsing 'MAIL FROM: 8BITMIME' should parse 'FROM: 8BITMIME' as data") 49 | assert.Equal(t, []string{"FROM:", "8BITMIME"}, cmd.arguments, "parsing 'MAIL FROM: 8BITMIME' should parse 'FROM:', '8BITMIME' as arguments") 50 | 51 | // valid command with no arguments 52 | cmd, err = parseCommand("RSET") 53 | assert.NoError(t, err, "parsing 'RSET' command shouldn't return error") 54 | assert.Equal(t, rsetCmd, cmd.commandCode, "parsing 'RSET' command should parse correct command code 'rsetCmd'") 55 | assert.Equal(t, "RSET", cmd.verb, "parsing 'RSET' command should parse correct verb 'RSET'") 56 | assert.Equal(t, "RSET", cmd.data, "parsing 'RSET' command with no arguments should parse empty data") 57 | assert.Equal(t, []string(nil), cmd.arguments, "parsing 'RSET' command with no arguments should parse empty arguments") 58 | 59 | // invalid command with no arguments 60 | cmd, err = parseCommand("RSET x") 61 | assert.Error(t, err, "parsing 'RSET' (command which shouldn't have arguments) command with arguments should return error") 62 | } 63 | 64 | func TestCommand_String(t *testing.T) { 65 | // non 7-bit ASCII command 66 | orig := "MAIL FROM: 8BITMIME" 67 | cmd, _ := parseCommand(orig) 68 | assert.Equal(t, orig, cmd.String(), "converting cmd back to string should return original parsed string") 69 | 70 | // non 7-bit ASCII command 71 | orig = "RSET" 72 | cmd, _ = parseCommand(orig) 73 | assert.Equal(t, orig, cmd.String(), "converting cmd back to string should return original parsed string") 74 | } 75 | -------------------------------------------------------------------------------- /envelope.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "net/mail" 8 | ) 9 | 10 | // Envelope represents a message envelope 11 | type Envelope struct { 12 | MailFrom *mail.Address // Envelope sender 13 | MailTo []*mail.Address // Envelope recipients 14 | Mail *mail.Message // Final message 15 | Priority int 16 | 17 | data *bytes.Buffer // data stores the header and message body 18 | headers map[string]string // New headers added by server 19 | } 20 | 21 | func NewEnvelope() *Envelope { 22 | return &Envelope{ 23 | MailTo: []*mail.Address{}, 24 | data: bytes.NewBufferString(""), 25 | headers: make(map[string]string), 26 | } 27 | } 28 | 29 | // Close the envelope before handing it futher 30 | func (e *Envelope) Close() (err error) { 31 | e.Mail, err = mail.ReadMessage(bufio.NewReader(e.data)) 32 | if err != nil { 33 | return 34 | } 35 | 36 | for headerKey, headerValue := range e.headers { 37 | if e.Mail.Header.Get(headerKey) != "" { 38 | e.Mail.Header[headerKey] = append(e.Mail.Header[headerKey], headerValue) 39 | } else { 40 | e.Mail.Header[headerKey] = []string{headerValue} 41 | } 42 | } 43 | return 44 | } 45 | 46 | // IsSet returns if the envelope is set 47 | func (e *Envelope) IsSet() bool { 48 | return e.MailFrom != nil 49 | } 50 | 51 | // Reader returns reader for envelope data 52 | func (e *Envelope) Reader() *bytes.Reader { 53 | return bytes.NewReader(e.data.Bytes()) 54 | } 55 | 56 | func (e *Envelope) Bytes() []byte { 57 | return e.data.Bytes() 58 | } 59 | 60 | // Reset resets envelope to initial state 61 | func (e *Envelope) Reset() error { 62 | e.MailTo = []*mail.Address{} 63 | e.MailFrom = nil 64 | if e.data != nil { 65 | e.data.Reset() 66 | } 67 | for key, _ := range e.headers { 68 | delete(e.headers, key) 69 | } 70 | return nil 71 | } 72 | 73 | // AddRecipient adds recipient to envelope recipients 74 | // returns error if maximum number of recipients is reached 75 | func (e *Envelope) AddRecipient(rcpt *mail.Address) error { 76 | e.MailTo = append(e.MailTo, rcpt) 77 | return nil 78 | } 79 | 80 | func (e *Envelope) BeginData() error { 81 | if len(e.MailTo) == 0 { 82 | return errors.New("554 5.5.1 Error: no valid recipients") 83 | } 84 | e.data = bytes.NewBuffer([]byte{}) 85 | return nil 86 | } 87 | 88 | // Write writes bytes into the envelope buffer 89 | func (e *Envelope) Write(line []byte) (int, error) { 90 | return e.data.Write(line) 91 | } 92 | 93 | // WriteString writes string into the envelope buffer 94 | func (e *Envelope) WriteString(line string) (int, error) { 95 | return e.data.WriteString(line) 96 | } 97 | 98 | // WriteLine writes data into the envelope followed by new line 99 | func (e *Envelope) WriteLine(line []byte) (int, error) { 100 | return e.data.Write(append(line, []byte("\r\n")...)) 101 | } 102 | -------------------------------------------------------------------------------- /envelope_test.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "bytes" 5 | "net/mail" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestEnvelope_AddRecipient(t *testing.T) { 12 | e1, _ := parseAddress("hello@example.com") 13 | env := Envelope{} 14 | env.AddRecipient(e1) 15 | assert.Equal(t, len(env.MailTo), 1, "add recipient should add recipient to envelope recipient list") 16 | } 17 | 18 | func TestEnvelope_IsSet(t *testing.T) { 19 | env := Envelope{} 20 | assert.Equal(t, env.IsSet(), false, "envelope is empty but acts as set") 21 | env.MailFrom, _ = parseAddress("hello@example.com") 22 | assert.Equal(t, env.IsSet(), true, "envelope is set but acts as empty") 23 | } 24 | 25 | func TestEnvelope_BeginData(t *testing.T) { 26 | env := NewEnvelope() 27 | assert.Error(t, env.BeginData(), "envelope recipient list is empty but allows begin data") 28 | e1, _ := parseAddress("hello@example.com") 29 | env.AddRecipient(e1) 30 | assert.NoError(t, env.BeginData(), "envelope is ready to receive data but reports an error") 31 | } 32 | 33 | func TestEnvelope_Reset(t *testing.T) { 34 | e1, _ := parseAddress("hello@example.com") 35 | e2, _ := parseAddress("helle@example.com") 36 | env := Envelope{ 37 | MailTo: []*mail.Address{e1}, 38 | MailFrom: e2, 39 | data: bytes.NewBufferString("hello there"), 40 | } 41 | env.Reset() 42 | assert.Equal(t, "", env.data.String(), "envelope data should be ampty after reset") 43 | assert.Nil(t, env.MailFrom, "mail from should be nil after reset") 44 | assert.Equal(t, 0, len(env.MailTo), "mail recipient should be empty after reset") 45 | } 46 | -------------------------------------------------------------------------------- /examples/mamail.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/matoous/gosmtp" 6 | "log" 7 | "net/mail" 8 | "os" 9 | ) 10 | 11 | var configFileName string 12 | 13 | func alwaysOkRcpt(adr *mail.Address) error { 14 | return nil 15 | } 16 | 17 | func main() { 18 | // set SMTP server and run 19 | server, err := gosmtp.NewServer(":3333", log.New(os.Stdout, "", log.LstdFlags)) 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | server.Hostname = "example.com" 25 | // authenticate all users 26 | server.Authenticator = func(peer *gosmtp.Peer, pw []byte) (bool, error) { 27 | fmt.Printf("User %s logged in\n", peer.Username) 28 | return true, nil 29 | } 30 | server.Handler = func(peer *gosmtp.Peer, envelope *gosmtp.Envelope) (string, error) { 31 | fmt.Printf("Mail peer:\n%#v\ndata:\n%#v\n", peer, envelope) 32 | return "1", nil 33 | } 34 | 35 | err = server.ListenAndServe() 36 | if err != nil { 37 | panic(err) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/matoous/gosmtp 2 | 3 | require ( 4 | github.com/davecgh/go-spew v1.1.1 // indirect 5 | github.com/go-errors/errors v1.0.1 6 | github.com/matoous/go-nanoid v0.0.0-20180926092311-3de1538a83bc 7 | github.com/signalsciences/tlstext v0.0.0-20170724030830-3693a8d42128 8 | github.com/stretchr/testify v1.6.1 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= 5 | github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= 6 | github.com/matoous/go-nanoid v0.0.0-20180926092311-3de1538a83bc h1:5wtRu6KKNRIzkeBu11K8cyM8iUcZ0TOq9zh9HIx5dIc= 7 | github.com/matoous/go-nanoid v0.0.0-20180926092311-3de1538a83bc/go.mod h1:soqXi4beH2aAljcVvgIDqekDtnM2UZkGl47fniwq3J4= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/signalsciences/tlstext v0.0.0-20170724030830-3693a8d42128 h1:Fn03yf/JAKLB5zE70S1BPuoosXBxNGnn96HapK/Wo+Y= 11 | github.com/signalsciences/tlstext v0.0.0-20170724030830-3693a8d42128/go.mod h1:DKD8bjL8ZiedHAgWtcpgZm6TBAnmAlImuyJX2whrm3k= 12 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 14 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 15 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 16 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 17 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 18 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 19 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 20 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 21 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 24 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 25 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 26 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 27 | -------------------------------------------------------------------------------- /limits.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import "time" 4 | 5 | // Limits hold all the session limitations - max attempts, sizes and timeouts 6 | type Limits struct { 7 | CmdInput time.Duration // client commands 8 | MsgInput time.Duration // total time for the email 9 | ReplyOut time.Duration // server reply time 10 | TLSSetup time.Duration // time limit for STARTTLS setup 11 | MsgSize int64 // max email size 12 | BadCmds int // bad commands limit 13 | MaxRcptCount int // maximum number of recipients of message 14 | } 15 | 16 | // DefaultLimits that are applied if you do not specify custom limits 17 | // Two minutes for command input and command replies, ten minutes for 18 | // receiving messages, and 5 Mbytes of message size. 19 | // 20 | // Note that these limits are not necessarily RFC compliant, although 21 | // they should be enough for real email clients, TODO change this to RFC compliant 22 | var DefaultLimits = Limits{ 23 | CmdInput: 2 * time.Minute, 24 | MsgInput: 10 * time.Minute, 25 | ReplyOut: 2 * time.Minute, 26 | TLSSetup: 4 * time.Minute, 27 | MsgSize: 5 * 1024 * 1024, 28 | BadCmds: 5, 29 | MaxRcptCount: 200, 30 | } 31 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import "fmt" 4 | 5 | // TODO MORE FROM https://tools.ietf.org/html/rfc821 6 | 7 | const ( 8 | // ClassSuccess specifies that the DSN is reporting a positive delivery 9 | // action. Detail sub-codes may provide notification of 10 | // transformations required for delivery. 11 | ClassSuccess = 2 12 | // ClassTransientFailure - a persistent transient failure is one in which the message as 13 | // sent is valid, but persistence of some temporary condition has 14 | // caused abandonment or delay of attempts to send the message. 15 | // If this code accompanies a delivery failure report, sending in 16 | // the future may be successful. 17 | ClassTransientFailure = 4 18 | // ClassPermanentFailure - a permanent failure is one which is not likely to be resolved 19 | // by resending the message in the current form. Some change to 20 | // the message or the destination must be made for successful 21 | // delivery. 22 | ClassPermanentFailure = 5 23 | ) 24 | 25 | // class is a type for ClassSuccess, ClassTransientFailure and ClassPermanentFailure constants 26 | type class int 27 | 28 | // String implements stringer for the class type 29 | func (c class) String() string { 30 | return fmt.Sprintf("%c00", c) 31 | } 32 | 33 | type subject int 34 | 35 | const ( 36 | // SubjectUndefined - There is no additional subject information available 37 | SubjectUndefined = 0 38 | // SubjectAddressing - The address status reports on the originator or destination address. 39 | // It may include address syntax or validity. 40 | // These errors can generally be corrected by the sender and retried. 41 | SubjectAddressing = 1 42 | // SubjectMailbox - Mailbox status indicates that something having to do with the mailbox has caused this DSN. 43 | // Mailbox issues are assumed to be under the general control of the recipient. 44 | SubjectMailbox = 2 45 | // SubjectMail - Mail system status indicates that something having to do with the destination system has caused this DSN. 46 | // System issues are assumed to be under the general control of the destination system administrator. 47 | SubjectMail = 3 48 | // SubjectNetwork - The networking or routing codes report status about the delivery system itself. 49 | // These system components include any necessary infrastructure such as directory and routing services. 50 | // Network issues are assumed to be under the control of the destination or intermediate system administrator. 51 | SubjectNetwork = 4 52 | // SubjectDelivery - The mail delivery protocol status codes report failures involving the message delivery protocol. 53 | // These failures include the full range of problems resulting from implementation errors or an unreliable connection. 54 | SubjectDelivery = 5 55 | // SubjectContent - The message content or media status codes report failures involving the content of the message. 56 | // These codes report failures due to translation, transcoding, or otherwise unsupported message media. 57 | // Message content or media issues are under the control of both the sender and the receiver, 58 | // both of which must support a common set of supported content-types. 59 | SubjectContent = 6 60 | // SubjectPolicy - The security or policy status codes report failures involving policies such as per-recipient or 61 | // per-host filtering and cryptographic operations. 62 | // Security and policy status issues are assumed to be under the control of either or both the sender and recipient. 63 | // Both the sender and recipient must permit the exchange of messages and arrange the exchange of necessary keys and 64 | // certificates for cryptographic operations. 65 | SubjectPolicy = 7 66 | ) 67 | 68 | // codeMap for mapping Enhanced Status Code to Basic Code 69 | // Mapping according to https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xml 70 | // This might not be entirely useful 71 | var codeMap = struct { 72 | m map[EnhancedStatusCode]int 73 | }{m: map[EnhancedStatusCode]int{ 74 | 75 | EnhancedStatusCode{ClassSuccess, OtherAddressStatus}: 250, 76 | EnhancedStatusCode{ClassSuccess, DestinationMailboxAddressValid}: 250, 77 | EnhancedStatusCode{ClassSuccess, OtherOrUndefinedMailSystemStatus}: 250, 78 | EnhancedStatusCode{ClassSuccess, OtherOrUndefinedProtocolStatus}: 250, 79 | EnhancedStatusCode{ClassSuccess, ConversionWithLossPerformed}: 250, 80 | EnhancedStatusCode{ClassSuccess, ".6.8"}: 252, 81 | EnhancedStatusCode{ClassSuccess, ".7.0"}: 220, 82 | 83 | EnhancedStatusCode{ClassTransientFailure, BadDestinationMailboxAddress}: 451, 84 | EnhancedStatusCode{ClassTransientFailure, BadSendersSystemAddress}: 451, 85 | EnhancedStatusCode{ClassTransientFailure, MailingListExpansionProblem}: 450, 86 | EnhancedStatusCode{ClassTransientFailure, OtherOrUndefinedMailSystemStatus}: 421, 87 | EnhancedStatusCode{ClassTransientFailure, MailSystemFull}: 452, 88 | EnhancedStatusCode{ClassTransientFailure, SystemNotAcceptingNetworkMessages}: 453, 89 | EnhancedStatusCode{ClassTransientFailure, NoAnswerFromHost}: 451, 90 | EnhancedStatusCode{ClassTransientFailure, BadConnection}: 421, 91 | EnhancedStatusCode{ClassTransientFailure, RoutingServerFailure}: 451, 92 | EnhancedStatusCode{ClassTransientFailure, NetworkCongestion}: 451, 93 | EnhancedStatusCode{ClassTransientFailure, OtherOrUndefinedProtocolStatus}: 451, 94 | EnhancedStatusCode{ClassTransientFailure, InvalidCommand}: 430, 95 | EnhancedStatusCode{ClassTransientFailure, TooManyRecipients}: 452, 96 | EnhancedStatusCode{ClassTransientFailure, InvalidCommandArguments}: 451, 97 | EnhancedStatusCode{ClassTransientFailure, ".7.0"}: 450, 98 | EnhancedStatusCode{ClassTransientFailure, ".7.1"}: 451, 99 | EnhancedStatusCode{ClassTransientFailure, ".7.12"}: 422, 100 | EnhancedStatusCode{ClassTransientFailure, ".7.15"}: 450, 101 | EnhancedStatusCode{ClassTransientFailure, ".7.24"}: 451, 102 | 103 | EnhancedStatusCode{ClassPermanentFailure, BadDestinationMailboxAddress}: 550, 104 | EnhancedStatusCode{ClassPermanentFailure, BadDestinationMailboxAddressSyntax}: 501, 105 | EnhancedStatusCode{ClassPermanentFailure, BadSendersSystemAddress}: 501, 106 | EnhancedStatusCode{ClassPermanentFailure, ".1.10"}: 556, 107 | EnhancedStatusCode{ClassPermanentFailure, MailboxFull}: 552, 108 | EnhancedStatusCode{ClassPermanentFailure, MessageLengthExceedsAdministrativeLimit}: 552, 109 | EnhancedStatusCode{ClassPermanentFailure, OtherOrUndefinedMailSystemStatus}: 550, 110 | EnhancedStatusCode{ClassPermanentFailure, MessageTooBigForSystem}: 552, 111 | EnhancedStatusCode{ClassPermanentFailure, RoutingServerFailure}: 550, 112 | EnhancedStatusCode{ClassPermanentFailure, OtherOrUndefinedProtocolStatus}: 501, 113 | EnhancedStatusCode{ClassPermanentFailure, InvalidCommand}: 500, 114 | EnhancedStatusCode{ClassPermanentFailure, SyntaxError}: 500, 115 | EnhancedStatusCode{ClassPermanentFailure, InvalidCommandArguments}: 501, 116 | EnhancedStatusCode{ClassPermanentFailure, ".5.6"}: 500, 117 | EnhancedStatusCode{ClassPermanentFailure, ConversionRequiredButNotSupported}: 554, 118 | EnhancedStatusCode{ClassPermanentFailure, ".6.6"}: 554, 119 | EnhancedStatusCode{ClassPermanentFailure, ".6.7"}: 553, 120 | EnhancedStatusCode{ClassPermanentFailure, ".6.8"}: 550, 121 | EnhancedStatusCode{ClassPermanentFailure, ".6.9"}: 550, 122 | EnhancedStatusCode{ClassPermanentFailure, ".7.0"}: 550, 123 | EnhancedStatusCode{ClassPermanentFailure, ".7.1"}: 551, 124 | EnhancedStatusCode{ClassPermanentFailure, ".7.2"}: 550, 125 | EnhancedStatusCode{ClassPermanentFailure, ".7.4"}: 504, 126 | EnhancedStatusCode{ClassPermanentFailure, ".7.8"}: 554, 127 | EnhancedStatusCode{ClassPermanentFailure, ".7.9"}: 534, 128 | EnhancedStatusCode{ClassPermanentFailure, ".7.10"}: 523, 129 | EnhancedStatusCode{ClassPermanentFailure, ".7.11"}: 524, 130 | EnhancedStatusCode{ClassPermanentFailure, ".7.13"}: 525, 131 | EnhancedStatusCode{ClassPermanentFailure, ".7.14"}: 535, 132 | EnhancedStatusCode{ClassPermanentFailure, ".7.15"}: 550, 133 | EnhancedStatusCode{ClassPermanentFailure, ".7.16"}: 552, 134 | EnhancedStatusCode{ClassPermanentFailure, ".7.17"}: 500, 135 | EnhancedStatusCode{ClassPermanentFailure, ".7.18"}: 500, 136 | EnhancedStatusCode{ClassPermanentFailure, ".7.19"}: 500, 137 | EnhancedStatusCode{ClassPermanentFailure, ".7.20"}: 550, 138 | EnhancedStatusCode{ClassPermanentFailure, ".7.21"}: 550, 139 | EnhancedStatusCode{ClassPermanentFailure, ".7.22"}: 550, 140 | EnhancedStatusCode{ClassPermanentFailure, ".7.23"}: 550, 141 | EnhancedStatusCode{ClassPermanentFailure, ".7.24"}: 550, 142 | EnhancedStatusCode{ClassPermanentFailure, ".7.25"}: 550, 143 | EnhancedStatusCode{ClassPermanentFailure, ".7.26"}: 550, 144 | EnhancedStatusCode{ClassPermanentFailure, ".7.27"}: 550, 145 | }} 146 | 147 | var ( 148 | // Codes is to be read-only, except in the init() function 149 | Codes Responses 150 | ) 151 | 152 | // Responses has some already pre-constructed responses 153 | type Responses struct { 154 | // The 500's 155 | FailLineTooLong string 156 | FailNestedMailCmd string 157 | FailNoSenderDataCmd string 158 | FailNoRecipientsDataCmd string 159 | FailUnrecognizedCmd string 160 | FailMaxUnrecognizedCmd string 161 | FailReadLimitExceededDataCmd string 162 | FailMessageSizeExceeded string 163 | FailReadErrorDataCmd string 164 | FailPathTooLong string 165 | FailInvalidAddress string 166 | FailLocalPartTooLong string 167 | FailInvalidExtension string 168 | FailAuthentication string 169 | FailUnqalifiedHostName string 170 | FailDomainTooLong string 171 | FailBackendNotRunning string 172 | FailBackendTransaction string 173 | FailTooBig string 174 | FailBackendTimeout string 175 | FailRcptCmd string 176 | FailCmdNotSupported string 177 | FailRelayAccessDenied string 178 | FailMailboxDoesntExist string 179 | FailMailboxFull string 180 | FailBadSenderMailboxAddressSyntax string 181 | FailBadDestinationMailboxAddressSyntax string 182 | FailAccessDenied string 183 | FailBadSequence string 184 | FailInvalidRecipient string 185 | FailEncryptionNeeded string 186 | FailMissingArgument string 187 | FailUndefinedSecurityStatus string 188 | 189 | // The 400's 190 | ErrorTooManyRecipients string 191 | ErrorRelayDenied string 192 | ErrorShutdown string 193 | ErrorRelayAccess string 194 | ErrorAuth string 195 | ErrorUnableToResolveHost string 196 | ErrorCmdParamNotImplemented string 197 | 198 | // The 200's 199 | SuccessAuthentication string 200 | SuccessMailCmd string 201 | SuccessRcptCmd string 202 | SuccessResetCmd string 203 | SuccessVerifyCmd string 204 | SuccessNoopCmd string 205 | SuccessQuitCmd string 206 | SuccessDataCmd string 207 | SuccessHelpCmd string 208 | SuccessStartTLSCmd string 209 | SuccessMessageQueued string 210 | } 211 | 212 | // Called automatically during package load to build up the Responses struct 213 | func init() { 214 | 215 | Codes = Responses{} 216 | 217 | Codes.FailLineTooLong = (&Response{ 218 | EnhancedCode: InvalidCommand, 219 | BasicCode: 554, 220 | Class: ClassPermanentFailure, 221 | Comment: "Line too long!", 222 | }).String() 223 | 224 | Codes.FailMailboxDoesntExist = (&Response{ 225 | EnhancedCode: InvalidCommand, 226 | BasicCode: 550, 227 | Class: ClassPermanentFailure, 228 | Comment: "Sorry, no mailbox here by that name!", 229 | }).String() 230 | 231 | Codes.FailNestedMailCmd = (&Response{ 232 | EnhancedCode: InvalidCommand, 233 | BasicCode: 503, 234 | Class: ClassPermanentFailure, 235 | Comment: "Nested mail command!", 236 | }).String() 237 | 238 | Codes.FailBadSequence = (&Response{ 239 | EnhancedCode: InvalidCommand, 240 | BasicCode: 503, 241 | Class: ClassPermanentFailure, 242 | Comment: "Bad sequence!", 243 | }).String() 244 | 245 | Codes.SuccessMailCmd = (&Response{ 246 | EnhancedCode: OtherAddressStatus, 247 | Class: ClassSuccess, 248 | }).String() 249 | 250 | Codes.SuccessHelpCmd = "214" 251 | 252 | Codes.SuccessRcptCmd = (&Response{ 253 | EnhancedCode: DestinationMailboxAddressValid, 254 | Class: ClassSuccess, 255 | }).String() 256 | 257 | Codes.SuccessResetCmd = Codes.SuccessMailCmd 258 | Codes.SuccessNoopCmd = (&Response{ 259 | EnhancedCode: OtherStatus, 260 | Class: ClassSuccess, 261 | }).String() 262 | 263 | Codes.ErrorUnableToResolveHost = (&Response{ 264 | Class: ClassTransientFailure, 265 | Comment: "Unable to resolve host!", 266 | BasicCode: 451, 267 | }).String() 268 | 269 | Codes.SuccessVerifyCmd = (&Response{ 270 | EnhancedCode: OtherOrUndefinedProtocolStatus, 271 | BasicCode: 252, 272 | Class: ClassSuccess, 273 | Comment: "Cannot verify user!", 274 | }).String() 275 | 276 | Codes.ErrorTooManyRecipients = (&Response{ 277 | EnhancedCode: TooManyRecipients, 278 | BasicCode: 452, 279 | Class: ClassTransientFailure, 280 | Comment: "Too many recipients!", 281 | }).String() 282 | 283 | Codes.FailTooBig = (&Response{ 284 | EnhancedCode: MessageLengthExceedsAdministrativeLimit, 285 | BasicCode: 552, 286 | Class: ClassTransientFailure, 287 | Comment: "Message exceeds maximum size!", 288 | }).String() 289 | 290 | Codes.FailMailboxFull = (&Response{ 291 | EnhancedCode: MailboxFull, 292 | BasicCode: 522, 293 | Class: ClassPermanentFailure, 294 | Comment: "Users mailbox is full!", 295 | }).String() 296 | 297 | Codes.ErrorRelayDenied = (&Response{ 298 | EnhancedCode: BadDestinationMailboxAddress, 299 | BasicCode: 454, 300 | Class: ClassTransientFailure, 301 | Comment: "Relay access denied!", 302 | }).String() 303 | 304 | Codes.ErrorAuth = (&Response{ 305 | EnhancedCode: OtherOrUndefinedMailSystemStatus, 306 | BasicCode: 454, 307 | Class: ClassTransientFailure, 308 | Comment: "Problem with auth!", 309 | }).String() 310 | 311 | Codes.SuccessQuitCmd = (&Response{ 312 | EnhancedCode: OtherStatus, 313 | BasicCode: 221, 314 | Class: ClassSuccess, 315 | Comment: "Bye!", 316 | }).String() 317 | 318 | Codes.FailNoSenderDataCmd = (&Response{ 319 | EnhancedCode: InvalidCommand, 320 | BasicCode: 503, 321 | Class: ClassPermanentFailure, 322 | Comment: "No sender!", 323 | }).String() 324 | 325 | Codes.FailNoRecipientsDataCmd = (&Response{ 326 | EnhancedCode: InvalidCommand, 327 | BasicCode: 503, 328 | Class: ClassPermanentFailure, 329 | Comment: "No recipients!", 330 | }).String() 331 | 332 | Codes.FailAccessDenied = (&Response{ 333 | EnhancedCode: DeliveryNotAuthorized, 334 | BasicCode: 530, 335 | Class: ClassPermanentFailure, 336 | Comment: "Authentication required!", 337 | }).String() 338 | 339 | Codes.FailAccessDenied = (&Response{ 340 | EnhancedCode: DeliveryNotAuthorized, 341 | BasicCode: 554, 342 | Class: ClassPermanentFailure, 343 | Comment: "Relay access denied!", 344 | }).String() 345 | 346 | Codes.ErrorRelayAccess = (&Response{ 347 | EnhancedCode: OtherOrUndefinedMailSystemStatus, 348 | BasicCode: 455, 349 | Class: ClassTransientFailure, 350 | Comment: "Oops, problem with relay access!", 351 | }).String() 352 | 353 | Codes.SuccessDataCmd = "354 Go ahead!" 354 | 355 | Codes.SuccessAuthentication = (&Response{ 356 | EnhancedCode: SecurityStatus, 357 | BasicCode: 235, 358 | Class: ClassSuccess, 359 | Comment: "Authentication successful!", 360 | }).String() 361 | 362 | Codes.SuccessStartTLSCmd = (&Response{ 363 | EnhancedCode: OtherStatus, 364 | BasicCode: 220, 365 | Class: ClassSuccess, 366 | Comment: "Ready to start TLS!", 367 | }).String() 368 | 369 | Codes.FailUnrecognizedCmd = (&Response{ 370 | EnhancedCode: InvalidCommand, 371 | BasicCode: 554, 372 | Class: ClassPermanentFailure, 373 | Comment: "Unrecognized command!", 374 | }).String() 375 | 376 | Codes.FailMaxUnrecognizedCmd = (&Response{ 377 | EnhancedCode: InvalidCommand, 378 | BasicCode: 554, 379 | Class: ClassPermanentFailure, 380 | Comment: "Too many unrecognized commands!", 381 | }).String() 382 | 383 | Codes.ErrorShutdown = (&Response{ 384 | EnhancedCode: OtherOrUndefinedMailSystemStatus, 385 | BasicCode: 421, 386 | Class: ClassTransientFailure, 387 | Comment: "Server is shutting down. Please try again later!", 388 | }).String() 389 | 390 | Codes.FailReadLimitExceededDataCmd = (&Response{ 391 | EnhancedCode: SyntaxError, 392 | BasicCode: 550, 393 | Class: ClassPermanentFailure, 394 | Comment: "ERR ", 395 | }).String() 396 | 397 | Codes.FailCmdNotSupported = (&Response{ 398 | BasicCode: 502, 399 | Class: ClassPermanentFailure, 400 | Comment: "Cmd not supported", 401 | }).String() 402 | 403 | Codes.FailUnqalifiedHostName = (&Response{ 404 | EnhancedCode: SyntaxError, 405 | BasicCode: 550, 406 | Class: ClassPermanentFailure, 407 | Comment: "Need fully-qualified hostname for domain part", 408 | }).String() 409 | 410 | Codes.FailReadErrorDataCmd = (&Response{ 411 | EnhancedCode: OtherOrUndefinedMailSystemStatus, 412 | BasicCode: 451, 413 | Class: ClassTransientFailure, 414 | Comment: "ERR ", 415 | }).String() 416 | 417 | Codes.FailPathTooLong = (&Response{ 418 | EnhancedCode: InvalidCommandArguments, 419 | BasicCode: 550, 420 | Class: ClassPermanentFailure, 421 | Comment: "Path too long", 422 | }).String() 423 | 424 | Codes.FailInvalidAddress = (&Response{ 425 | EnhancedCode: InvalidCommandArguments, 426 | BasicCode: 501, 427 | Class: ClassPermanentFailure, 428 | Comment: "Syntax: MAIL FROM:
[EXT]", 429 | }).String() 430 | 431 | Codes.FailInvalidRecipient = (&Response{ 432 | EnhancedCode: InvalidCommandArguments, 433 | BasicCode: 501, 434 | Class: ClassPermanentFailure, 435 | Comment: "Syntax: RCPT TO:
", 436 | }).String() 437 | 438 | Codes.FailInvalidExtension = (&Response{ 439 | EnhancedCode: InvalidCommandArguments, 440 | BasicCode: 501, 441 | Class: ClassPermanentFailure, 442 | Comment: "Invalid arguments", 443 | }).String() 444 | 445 | Codes.FailLocalPartTooLong = (&Response{ 446 | EnhancedCode: InvalidCommandArguments, 447 | BasicCode: 550, 448 | Class: ClassPermanentFailure, 449 | Comment: "Local part too long, cannot exceed 64 characters", 450 | }).String() 451 | 452 | Codes.FailDomainTooLong = (&Response{ 453 | EnhancedCode: InvalidCommandArguments, 454 | BasicCode: 550, 455 | Class: ClassPermanentFailure, 456 | Comment: "Domain cannot exceed 255 characters", 457 | }).String() 458 | 459 | Codes.FailMissingArgument = (&Response{ 460 | EnhancedCode: InvalidCommandArguments, 461 | BasicCode: 550, 462 | Class: ClassPermanentFailure, 463 | Comment: "Argument is missing in you command", 464 | }).String() 465 | 466 | Codes.FailBackendNotRunning = (&Response{ 467 | EnhancedCode: OtherOrUndefinedProtocolStatus, 468 | BasicCode: 554, 469 | Class: ClassPermanentFailure, 470 | Comment: "Transaction failed - backend not running ", 471 | }).String() 472 | 473 | Codes.FailBackendTransaction = (&Response{ 474 | EnhancedCode: OtherOrUndefinedProtocolStatus, 475 | BasicCode: 554, 476 | Class: ClassPermanentFailure, 477 | Comment: "ERR ", 478 | }).String() 479 | 480 | Codes.SuccessMessageQueued = (&Response{ 481 | EnhancedCode: OtherStatus, 482 | BasicCode: 250, 483 | Class: ClassSuccess, 484 | Comment: "OK Queued as ", 485 | }).String() 486 | 487 | Codes.FailBackendTimeout = (&Response{ 488 | EnhancedCode: OtherOrUndefinedProtocolStatus, 489 | BasicCode: 554, 490 | Class: ClassPermanentFailure, 491 | Comment: "ERR transaction timeout", 492 | }).String() 493 | 494 | Codes.FailAuthentication = (&Response{ 495 | EnhancedCode: OtherOrUndefinedProtocolStatus, 496 | BasicCode: 535, 497 | Class: ClassPermanentFailure, 498 | Comment: "ERR Authentication failed", 499 | }).String() 500 | 501 | Codes.ErrorCmdParamNotImplemented = (&Response{ 502 | EnhancedCode: InvalidCommandArguments, 503 | BasicCode: 504, 504 | Class: ClassPermanentFailure, 505 | Comment: "ERR Command parameter not implemented", 506 | }).String() 507 | 508 | Codes.FailRcptCmd = (&Response{ 509 | EnhancedCode: BadDestinationMailboxAddress, 510 | BasicCode: 550, 511 | Class: ClassPermanentFailure, 512 | Comment: "User unknown in local recipient table", 513 | }).String() 514 | 515 | Codes.FailBadSenderMailboxAddressSyntax = (&Response{ 516 | EnhancedCode: BadSendersMailboxAddressSyntax, 517 | BasicCode: 501, 518 | Class: ClassPermanentFailure, 519 | Comment: "Bad sender address syntax", 520 | }).String() 521 | 522 | Codes.FailBadDestinationMailboxAddressSyntax = (&Response{ 523 | EnhancedCode: BadSendersMailboxAddressSyntax, 524 | BasicCode: 501, 525 | Class: ClassPermanentFailure, 526 | Comment: "Bad sender address syntax", 527 | }).String() 528 | 529 | Codes.FailEncryptionNeeded = (&Response{ 530 | EnhancedCode: EncryptionNeeded, 531 | BasicCode: 523, 532 | Class: ClassPermanentFailure, 533 | Comment: "TLS required, use STARTTLS", 534 | }).String() 535 | 536 | Codes.FailUndefinedSecurityStatus = (&Response{ 537 | EnhancedCode: SecurityStatus, 538 | BasicCode: 550, 539 | Class: ClassPermanentFailure, 540 | Comment: "Undefined security failure", 541 | }).String() 542 | } 543 | 544 | // DefaultMap contains defined default codes (RfC 3463) 545 | const ( 546 | OtherStatus = ".0.0" 547 | OtherAddressStatus = ".1.0" 548 | BadDestinationMailboxAddress = ".1.1" 549 | BadDestinationSystemAddress = ".1.2" 550 | BadDestinationMailboxAddressSyntax = ".1.3" 551 | DestinationMailboxAddressAmbiguous = ".1.4" 552 | DestinationMailboxAddressValid = ".1.5" 553 | MailboxHasMoved = ".1.6" 554 | BadSendersMailboxAddressSyntax = ".1.7" 555 | BadSendersSystemAddress = ".1.8" 556 | OtherOrUndefinedMailboxStatus = ".2.0" 557 | MailboxDisabled = ".2.1" 558 | MailboxFull = ".2.2" 559 | MessageLengthExceedsAdministrativeLimit = ".2.3" 560 | MailingListExpansionProblem = ".2.4" 561 | OtherOrUndefinedMailSystemStatus = ".3.0" 562 | MailSystemFull = ".3.1" 563 | SystemNotAcceptingNetworkMessages = ".3.2" 564 | SystemNotCapableOfSelectedFeatures = ".3.3" 565 | MessageTooBigForSystem = ".3.4" 566 | OtherOrUndefinedNetworkOrRoutingStatus = ".4.0" 567 | NoAnswerFromHost = ".4.1" 568 | BadConnection = ".4.2" 569 | RoutingServerFailure = ".4.3" 570 | UnableToRoute = ".4.4" 571 | NetworkCongestion = ".4.5" 572 | RoutingLoopDetected = ".4.6" 573 | DeliveryTimeExpired = ".4.7" 574 | OtherOrUndefinedProtocolStatus = ".5.0" 575 | InvalidCommand = ".5.1" 576 | SyntaxError = ".5.2" 577 | TooManyRecipients = ".5.3" 578 | InvalidCommandArguments = ".5.4" 579 | WrongProtocolVersion = ".5.5" 580 | OtherOrUndefinedMediaError = ".6.0" 581 | MediaNotSupported = ".6.1" 582 | ConversionRequiredAndProhibited = ".6.2" 583 | ConversionRequiredButNotSupported = ".6.3" 584 | ConversionWithLossPerformed = ".6.4" 585 | ConversionFailed = ".6.5" 586 | SecurityStatus = ".7.0" 587 | DeliveryNotAuthorized = ".7.1" 588 | MailingListExpansionProhibited = ".7.2" 589 | SecurityConversionRequiredButNotSupported = ".7.3" 590 | EncryptionNeeded = ".7.10" 591 | ) 592 | 593 | var defaultTexts = struct { 594 | m map[EnhancedStatusCode]string 595 | }{m: map[EnhancedStatusCode]string{ 596 | EnhancedStatusCode{ClassSuccess, ".0.0"}: "OK", 597 | EnhancedStatusCode{ClassSuccess, ".1.0"}: "OK", 598 | EnhancedStatusCode{ClassSuccess, ".1.5"}: "OK", 599 | EnhancedStatusCode{ClassSuccess, ".5.0"}: "OK", 600 | EnhancedStatusCode{ClassTransientFailure, ".5.3"}: "Too many recipients", 601 | EnhancedStatusCode{ClassTransientFailure, ".5.4"}: "Relay access denied", 602 | EnhancedStatusCode{ClassPermanentFailure, ".5.1"}: "Invalid command", 603 | }} 604 | 605 | // Response type for Stringer interface 606 | type Response struct { 607 | EnhancedCode subjectDetail 608 | BasicCode int 609 | Class class 610 | // Comment is optional 611 | Comment string 612 | } 613 | 614 | // it looks like this ".5.4" 615 | type subjectDetail string 616 | 617 | // EnhancedStatus are the ones that look like 2.1.0 618 | type EnhancedStatusCode struct { 619 | Class class 620 | SubjectDetailCode subjectDetail 621 | } 622 | 623 | // String returns a string representation of EnhancedStatus 624 | func (e EnhancedStatusCode) String() string { 625 | return fmt.Sprintf("%d%s", e.Class, e.SubjectDetailCode) 626 | } 627 | 628 | // String returns a custom Response as a string 629 | func (r *Response) String() string { 630 | 631 | basicCode := r.BasicCode 632 | comment := r.Comment 633 | if len(comment) == 0 && r.BasicCode == 0 { 634 | var ok bool 635 | if comment, ok = defaultTexts.m[EnhancedStatusCode{r.Class, r.EnhancedCode}]; !ok { 636 | switch r.Class { 637 | case 2: 638 | comment = "OK" 639 | case 4: 640 | comment = "Temporary failure." 641 | case 5: 642 | comment = "Permanent failure." 643 | } 644 | } 645 | } 646 | e := EnhancedStatusCode{r.Class, r.EnhancedCode} 647 | if r.BasicCode == 0 { 648 | basicCode = getBasicStatusCode(e) 649 | } 650 | 651 | return fmt.Sprintf("%d %s %s", basicCode, e.String(), comment) 652 | } 653 | 654 | // getBasicStatusCode gets the basic status code from codeMap, or fallback code if not mapped 655 | func getBasicStatusCode(e EnhancedStatusCode) int { 656 | if val, ok := codeMap.m[e]; ok { 657 | return val 658 | } 659 | // Fallback if code is not defined 660 | return int(e.Class) * 100 661 | } 662 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net" 10 | "net/mail" 11 | "sync" 12 | "time" 13 | 14 | "github.com/matoous/go-nanoid" 15 | ) 16 | 17 | /* 18 | MailHandler is object on which func Handle(envelope, user) is called after the whole mail is received 19 | MailHandler can be for example object which passes the email to MDA (mail delivery agent) 20 | for remote delivery or to Dovercot to save the mail to the users inbox 21 | */ 22 | type MailHandler interface { 23 | Handle(envelope *Envelope, user string) (id string, err error) 24 | } 25 | 26 | // ErrorRecipientNotFound is returned when the email is inbound but the user is not found 27 | var ErrorRecipientNotFound = errors.New("Couldn't find recipient with given email address") 28 | 29 | // ErrorRecipientsMailboxFull is returned when the user's mailbox is full 30 | var ErrorRecipientsMailboxFull = errors.New("Recipients mailbox is full") 31 | 32 | /* 33 | Server - full feature, RFC compliant, SMTP server implementation 34 | */ 35 | type Server struct { 36 | sync.Mutex 37 | Addr string // TCP address to listen on, ":25" if empty 38 | Hostname string // hostname, e.g. the domain which the server runs on 39 | TLSConfig *tls.Config // TLS configuration 40 | TLSOnly bool 41 | log *log.Logger // servers logger 42 | authMechanisms []string // announced authentication mechanisms 43 | 44 | shuttingDown bool // is the server shutting down? 45 | 46 | // Limits 47 | Limits Limits 48 | 49 | // New e-mails are handed off to this function. 50 | // Can be left empty for a NOOP server. 51 | // Returned ID should be ID of the queued email if the email is put into outgoing queue 52 | // If an error is returned, it will be reported in the SMTP session. 53 | Handler func(peer *Peer, env *Envelope) (string, error) 54 | 55 | // Enable PLAIN/LOGIN authentication 56 | Authenticator func(peer *Peer, password []byte) (bool, error) 57 | 58 | // Enable various checks during the SMTP session. 59 | // Can be left empty for no restrictions. 60 | // If an error is returned, it will be reported in the SMTP session. 61 | // Use the Error struct for access to error codes. 62 | ConnectionChecker func(peer *Peer) error // Called upon new connection. 63 | HeloChecker func(peer *Peer, name string) error // Called after HELO/EHLO. 64 | SenderChecker func(peer *Peer, addr *mail.Address) error // Called after MAIL FROM. 65 | RecipientChecker func(peer *Peer, addr *mail.Address) error // Called after each RCPT TO. 66 | } 67 | 68 | // Auth sets the authentication function and authentication mechanisms which will be used 69 | func (srv *Server) Auth(f func(*Peer, []byte) (bool, error), mechanisms ...string) error { 70 | if len(mechanisms) != 0 { 71 | // Check that all authenticatedUser configured authentication mechanisms are support 72 | for _, mech := range mechanisms { 73 | if !stringInSlice(mech, SupportedAuthMechanisms) { 74 | return fmt.Errorf("%v authentication mechanism is not supported", mech) 75 | } 76 | } 77 | } else { 78 | mechanisms = SupportedAuthMechanisms 79 | } 80 | srv.authMechanisms = mechanisms 81 | srv.Authenticator = f 82 | return nil 83 | } 84 | 85 | /* 86 | NewServer creates new server 87 | */ 88 | func NewServer(port string, logger *log.Logger, limits ...Limits) (*Server, error) { 89 | s := &Server{ 90 | Addr: port, 91 | log: logger, 92 | shuttingDown: false, 93 | } 94 | // limits are optional, if no limits were provided, use the default ones 95 | if len(limits) == 1 { 96 | s.Limits = limits[0] 97 | } else { 98 | s.Limits = DefaultLimits 99 | } 100 | return s, nil 101 | } 102 | 103 | // ListenAndServe listens on the TCP network address and then 104 | // calls Serve to handle requests on incoming connections. 105 | // Connections are handled securely if it is available 106 | func (srv *Server) ListenAndServe() error { 107 | if srv.TLSConfig != nil { 108 | l, err := tls.Listen("tcp", srv.Addr, srv.TLSConfig) 109 | if err != nil { 110 | return err 111 | } 112 | return srv.Serve(l) 113 | } 114 | l, err := net.Listen("tcp", srv.Addr) 115 | if err != nil { 116 | return err 117 | } 118 | return srv.Serve(l) 119 | } 120 | 121 | // Generate new context upon connection 122 | func (srv *Server) newSession(conn net.Conn) *session { 123 | id, err := gonanoid.Nanoid() 124 | if err != nil { 125 | // generating nanoid shouldn't really fail, and if, panicing is OK 126 | panic(err) 127 | } 128 | 129 | s := &session{ 130 | id: id, 131 | conn: conn, 132 | bufio: bufio.NewReadWriter( 133 | bufio.NewReader(conn), 134 | bufio.NewWriter(conn), 135 | ), 136 | srv: srv, 137 | envelope: NewEnvelope(), 138 | start: time.Now(), 139 | log: srv.log, 140 | peer: &Peer{ 141 | Addr: conn.RemoteAddr(), 142 | ServerName: srv.Hostname, 143 | }, 144 | } 145 | 146 | var tlsConn *tls.Conn 147 | tlsConn, s.tls = conn.(*tls.Conn) 148 | if s.tls { 149 | state := tlsConn.ConnectionState() 150 | s.peer.TLS = &state 151 | } 152 | 153 | // set split function so it reads to new line or 1024 bytes max 154 | return s 155 | } 156 | 157 | // Serve incoming connections 158 | // Creates new session for each connection and starts go routine to handle it 159 | func (srv *Server) Serve(ln net.Listener) error { 160 | defer ln.Close() 161 | for { 162 | conn, err := ln.Accept() 163 | if err != nil { 164 | if netError, ok := err.(net.Error); ok && netError.Temporary() { 165 | srv.log.Printf("temporary accept error %s", err.Error()) 166 | continue 167 | } 168 | return err 169 | } 170 | s := srv.newSession(conn) 171 | go s.Serve() 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "log" 6 | "net" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | func init() { 12 | srv, err := NewServer(":4343", log.New(os.Stdout, "", log.LstdFlags)) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | go func() { 18 | err := srv.ListenAndServe() 19 | if err != nil { 20 | panic(err) 21 | } 22 | }() 23 | } 24 | 25 | func TestServer_Serve(t *testing.T) { 26 | conn, err := net.Dial("tcp", "localhost:4343") 27 | assert.NoError(t, err, "it should be possible to connect to the server") 28 | if err == nil { 29 | conn.Close() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/tls" 7 | "fmt" 8 | "log" 9 | "net" 10 | "strconv" 11 | "strings" 12 | "time" 13 | "unicode" 14 | ) 15 | 16 | type sessionState int 17 | 18 | const ( 19 | sessionStateInit sessionState = iota 20 | sessionStateGotMail 21 | sessionStateGotRcpt 22 | sessionStateReadyForData 23 | sessionStateGettingData 24 | sessionStateDataDone 25 | sessionStateAborted 26 | sessionStateWaitingForQuit 27 | ) 28 | 29 | // Protocol represents the protocol used in the SMTP session 30 | type Protocol string 31 | 32 | const ( 33 | SMTP Protocol = "SMTP" // SMTP - plain old SMTP 34 | ESMTP = "ESMTP" // ESMTP - Extended SMTP 35 | ) 36 | 37 | // Peer represents the client connecting to the server 38 | type Peer struct { 39 | HeloName string 40 | HeloType string 41 | Protocol Protocol 42 | ServerName string 43 | Username string 44 | Authenticated bool 45 | Addr net.Addr 46 | TLS *tls.ConnectionState 47 | AdditionalField map[string]interface{} 48 | } 49 | 50 | // session wraps underlying SMTP connection for easier handling 51 | type session struct { 52 | conn net.Conn // connection 53 | bufio *bufio.ReadWriter // buffered input/output 54 | id string // email id 55 | 56 | envelope *Envelope // session envelope 57 | state sessionState // session state 58 | badCommandsCount int // amount of bad commands 59 | vrfyCount int // amount of vrfy commands received during current session 60 | start time.Time // start time of the session 61 | bodyType string 62 | 63 | peer *Peer 64 | 65 | // tls info 66 | tls bool // tls enabled 67 | tlsState tls.ConnectionState 68 | 69 | // hello info 70 | helloType int 71 | helloHost string 72 | helloSeen bool 73 | 74 | log *log.Logger // logger 75 | srv *Server // serve handling this request 76 | } 77 | 78 | // Reset resets current session, happens upon MAIL, EHLO, HELO and RSET 79 | func (s *session) Reset() { 80 | s.envelope.Reset() 81 | s.state = sessionStateInit 82 | } 83 | 84 | // DoneAndReset resets current after receiving DATA successfully 85 | func (s *session) ReadLine() (string, error) { 86 | input, err := s.bufio.ReadString('\n') 87 | if err != nil { 88 | return "", err 89 | } 90 | // trim \r\n 91 | return input[:len(input)-2], nil 92 | } 93 | 94 | func (s *session) Out(msgs ...string) { 95 | // log 96 | s.log.Printf("INFO: returning msg: '%v'", msgs) 97 | 98 | s.conn.SetWriteDeadline(time.Now().Add(DefaultLimits.ReplyOut)) 99 | for _, msg := range msgs { 100 | s.bufio.WriteString(msg) 101 | s.bufio.Write([]byte("\r\n")) 102 | } 103 | if err := s.bufio.Flush(); err != nil { 104 | s.log.Printf("ERROR: flush error: %s", err.Error()) 105 | s.state = sessionStateAborted 106 | } 107 | } 108 | 109 | // Serve - serve given session 110 | // scans command from input and handles given commands accordingly, any failure will result to immediate abort 111 | // of connection 112 | func (s *session) Serve() { 113 | defer s.conn.Close() 114 | 115 | // send welcome 116 | s.handleWelcome() 117 | 118 | // for each received command 119 | for { 120 | // TODO can we? 121 | if s.badCommandsCount >= s.srv.Limits.BadCmds { 122 | s.Out(Codes.FailMaxUnrecognizedCmd) 123 | s.state = sessionStateAborted 124 | break 125 | } 126 | line, err := s.ReadLine() 127 | if err != nil { 128 | s.log.Printf("ERROR: %s", err.Error()) 129 | break 130 | } 131 | cmd, err := parseCommand(strings.TrimRightFunc(line, unicode.IsSpace)) 132 | if err != nil { 133 | s.log.Printf("ERROR: unrecognized command: '%s'\n", strings.TrimRightFunc(line, unicode.IsSpace)) 134 | s.Out(Codes.FailUnrecognizedCmd) 135 | s.badCommandsCount++ 136 | continue 137 | } 138 | if s.state == sessionStateWaitingForQuit && cmd.commandCode != quitCmd { 139 | s.Out(Codes.FailBadSequence) 140 | s.badCommandsCount++ 141 | continue 142 | } 143 | s.log.Printf("INFO: received command: '%s'", cmd.String()) 144 | // select the right handler by commandCode 145 | handler := handlers[cmd.commandCode] 146 | handler(s, cmd) 147 | if s.state == sessionStateAborted { 148 | break 149 | } 150 | // TODO timeout might differ as per https://tools.ietf.org/html/rfc5321#section-4.5.3.2 151 | s.conn.SetReadDeadline(time.Now().Add(s.srv.Limits.CmdInput)) 152 | } 153 | } 154 | 155 | // send Welcome upon new session creation 156 | func (s *session) handleWelcome() { 157 | s.Out(fmt.Sprintf("220 %s ESMTP gomstp(0.0.1) I'm mr. Meeseeks, look at me!", s.peer.ServerName)) 158 | /* 159 | The SMTP protocol allows a server to formally reject a mail session 160 | while still allowing the initial connection as follows: a 554 161 | response MAY be given in the initial connection opening message 162 | instead of the 220. A server taking this approach MUST still wait 163 | for the client to send a QUIT (see Section 4.1.1.10) before closing 164 | the connection and SHOULD respond to any intervening commands with 165 | "503 bad sequence of commands". Since an attempt to make an SMTP 166 | connection to such a system is probably in error, a server returning 167 | a 554 response on connection opening SHOULD provide enough 168 | information in the reply text to facilitate debugging of the sending 169 | system. 170 | */ 171 | } 172 | 173 | // handle Ehlo command 174 | func handleEhlo(s *session, cmd *command) { 175 | s.Reset() 176 | 177 | s.helloSeen = true 178 | s.helloType = cmd.commandCode 179 | // TODO chec cmd args 180 | s.helloHost = cmd.arguments[0] 181 | // TODO check sending host (SPF) 182 | if s.srv.HeloChecker != nil { 183 | if err := s.srv.HeloChecker(s.peer, s.helloHost); err != nil { 184 | s.Out("550 " + err.Error()) 185 | } 186 | } 187 | 188 | ehloResp := make([]string, 0, 10) 189 | 190 | ehloResp = append(ehloResp, fmt.Sprintf("250-%v hello %v", "mail.example", s.conn.RemoteAddr())) 191 | // https://tools.ietf.org/html/rfc6152 192 | ehloResp = append(ehloResp, "250-8BITMIME") 193 | // https://tools.ietf.org/html/rfc3030 194 | ehloResp = append(ehloResp, "250-CHUNKING") 195 | ehloResp = append(ehloResp, "250-BINARYMIME") 196 | // https://tools.ietf.org/html/rfc6531 197 | ehloResp = append(ehloResp, "250-SMTPUTF8") 198 | // https://tools.ietf.org/html/rfc2920 199 | ehloResp = append(ehloResp, "250-PIPELINING") 200 | // https://tools.ietf.org/html/rfc6710 201 | ehloResp = append(ehloResp, "250-MT-PRIORITY") 202 | // https://tools.ietf.org/html/rfc3207 203 | if s.srv.TLSConfig != nil { // do tls for this server 204 | if !s.tls { // already in tls stream 205 | ehloResp = append(ehloResp, "250-STARTTLS") 206 | } 207 | } 208 | // https://tools.ietf.org/html/rfc4954 209 | /* 210 | RFC4954 notes: A server implementation MUST 211 | implement a configuration in which it does NOT 212 | permit any plaintext password mechanisms, unless 213 | either the STARTTLS [SMTP-TLS] command has been negotiated... 214 | */ 215 | if len(s.srv.authMechanisms) != 0 && s.srv.TLSConfig != nil { 216 | ehloResp = append(ehloResp, "250-AUTH "+strings.Join(s.srv.authMechanisms, " ")) 217 | } 218 | // https://tools.ietf.org/html/rfc821 219 | ehloResp = append(ehloResp, "250-HELP") 220 | // https://tools.ietf.org/html/rfc1870 221 | ehloResp = append(ehloResp, "250 SIZE 35882577") // from gmail TODO 222 | 223 | s.Out(ehloResp...) 224 | } 225 | 226 | // handle Helo command 227 | func handleHelo(s *session, cmd *command) { 228 | s.Reset() 229 | s.helloSeen = true 230 | s.helloType = cmd.commandCode 231 | // TODO check cmd args 232 | s.helloHost = cmd.arguments[0] 233 | // TODO check sending host (SPF) 234 | if s.srv.HeloChecker != nil { 235 | if err := s.srv.HeloChecker(s.peer, s.helloHost); err != nil { 236 | s.Out("550 " + err.Error()) 237 | } 238 | } 239 | 240 | s.Out(fmt.Sprintf("250 %v hello %v", "mail.example", s.conn.RemoteAddr())) 241 | } 242 | 243 | // start TLS 244 | func handleStartTLS(s *session, cmd *command) { 245 | // already started TLS 246 | if s.tls { 247 | s.badCommandsCount++ 248 | s.Out(Codes.FailBadSequence) 249 | return 250 | } 251 | 252 | if s.srv.TLSConfig == nil { 253 | s.Out(Codes.FailCmdNotSupported) 254 | return 255 | } 256 | 257 | s.Out(Codes.SuccessStartTLSCmd) 258 | 259 | // set timeout for TLS connection negotiation 260 | s.conn.SetDeadline(time.Now().Add(DefaultLimits.TLSSetup)) 261 | secureConn := tls.Server(s.conn, s.srv.TLSConfig) 262 | 263 | // TLS handshake 264 | if err := secureConn.Handshake(); err != nil { 265 | s.log.Printf("ERROR: start tls: '%s'", err.Error()) 266 | // TODO should we abort? 267 | s.Out(Codes.FailUndefinedSecurityStatus) 268 | return 269 | } 270 | 271 | // reset session 272 | s.Reset() 273 | s.conn = secureConn 274 | s.bufio = bufio.NewReadWriter( 275 | bufio.NewReader(s.conn), 276 | bufio.NewWriter(s.conn), 277 | ) 278 | s.tls = true 279 | s.tlsState = secureConn.ConnectionState() 280 | s.peer.TLS = &s.tlsState 281 | s.state = sessionStateInit 282 | } 283 | 284 | func handleMail(s *session, cmd *command) { 285 | /* 286 | This command tells the SMTP-receiver that a new mail transaction is 287 | starting and to reset all its state tables and buffers, including any 288 | recipients or mail data. 289 | */ 290 | s.Reset() 291 | 292 | if !s.tls && s.srv.TLSOnly { 293 | s.Out(Codes.FailEncryptionNeeded) 294 | return 295 | } 296 | 297 | // require authentication if set in settings 298 | if len(s.srv.authMechanisms) != 0 && !s.peer.Authenticated { 299 | s.Out(Codes.FailAccessDenied) 300 | return 301 | } 302 | 303 | // nested mail command 304 | if s.envelope.IsSet() { 305 | s.Out(Codes.FailNestedMailCmd) 306 | return 307 | } 308 | 309 | args := cmd.arguments 310 | if len(args) == 0 { 311 | s.log.Print("DEBUG: Empty arguments for MAIL cmd") 312 | s.Out(Codes.FailInvalidAddress) 313 | return 314 | } 315 | 316 | // to lower and check if start with from: and is not empty 317 | from := strings.ToLower(strings.TrimSpace(args[0])) 318 | if from == "" || !strings.HasPrefix(from, "from:") { 319 | s.log.Print("DEBUG: Invalid address for MAIL cmd") 320 | s.Out(Codes.FailInvalidAddress) 321 | return 322 | } 323 | 324 | fromParts := strings.Split(from, ":") 325 | if len(fromParts) < 2 { 326 | s.Out(Codes.FailInvalidAddress) 327 | return 328 | } 329 | 330 | mailFrom, err := parseAddress(fromParts[1]) 331 | if err != nil { 332 | s.Out(Codes.FailInvalidAddress) 333 | return 334 | } 335 | 336 | if s.srv.SenderChecker != nil { 337 | if err := s.srv.SenderChecker(s.peer, mailFrom); err != nil { 338 | s.Out(Codes.FailAccessDenied + " " + err.Error()) 339 | return 340 | } 341 | } 342 | 343 | s.envelope.MailFrom = mailFrom 344 | args = args[1:] 345 | 346 | // extensions size 347 | if len(args) > 0 { 348 | for _, ext := range args { 349 | extValue := strings.Split(ext, "=") 350 | if len(extValue) != 2 { 351 | s.Out(Codes.FailInvalidAddress) 352 | return 353 | } 354 | switch strings.ToUpper(extValue[0]) { 355 | case "SIZE": 356 | size, err := strconv.ParseInt(extValue[1], 10, 64) 357 | if err != nil { 358 | s.Out(Codes.FailInvalidExtension) 359 | return 360 | } 361 | if int64(size) > DefaultLimits.MsgSize { 362 | s.Out(Codes.FailTooBig) 363 | return 364 | } 365 | case "BODY": 366 | // body-value ::= "7BIT" / "8BITMIME" / "BINARYMIME" 367 | s.bodyType = extValue[1] 368 | case "ALT-ADDRESS": 369 | /* 370 | One optional parameter, ALT-ADDRESS, is added to the MAIL and 371 | RCPT commands of SMTP. ALT-ADDRESS specifies an all-ASCII 372 | address which can be used as a substitute for the corresponding 373 | primary (i18mail) address when downgrading. 374 | */ 375 | case "AUTH": 376 | /* 377 | An optional parameter using the keyword "AUTH" is added to the 378 | MAIL FROM command, and extends the maximum line length of the 379 | MAIL FROM command by 500 characters. 380 | */ 381 | case "MT-PRIORITY": 382 | /* 383 | https://tools.ietf.org/html/rfc6710 384 | */ 385 | priority, err := strconv.ParseInt(extValue[1], 10, 64) 386 | if err != nil { 387 | s.Out(Codes.FailInvalidExtension) 388 | return 389 | } 390 | if priority > 9 || priority < -9 { 391 | s.Out(Codes.FailInvalidExtension) 392 | return 393 | } 394 | s.envelope.Priority = int(priority) 395 | default: 396 | s.Out("555") 397 | s.envelope.MailFrom = nil 398 | } 399 | } 400 | } 401 | 402 | // validate FQN 403 | if err := IsFQN(s.envelope.MailFrom); err != "" { 404 | s.Out(err) 405 | return 406 | } 407 | 408 | switch s.state { 409 | case sessionStateGotRcpt: 410 | s.state = sessionStateReadyForData 411 | case sessionStateInit: 412 | s.state = sessionStateGotMail 413 | default: 414 | s.state = sessionStateAborted 415 | s.Out(Codes.FailBadSequence) 416 | return 417 | } 418 | s.Out(Codes.SuccessMailCmd) 419 | } 420 | 421 | func handleRcpt(s *session, cmd *command) { 422 | // if auth is required 423 | if len(s.srv.authMechanisms) != 0 && !s.peer.Authenticated { 424 | s.Out(Codes.FailAccessDenied) 425 | return 426 | } 427 | 428 | // HELO/EHLO needs to be first 429 | if !s.helloSeen { 430 | s.Out(Codes.FailBadSequence) 431 | return 432 | } 433 | 434 | // check recipients limit 435 | if len(s.envelope.MailTo) > DefaultLimits.MaxRcptCount { 436 | s.Out(Codes.ErrorTooManyRecipients) 437 | return 438 | } 439 | 440 | args := cmd.arguments 441 | if args == nil { 442 | s.Out(Codes.FailInvalidRecipient) 443 | } 444 | 445 | toParts := strings.Split(args[0], ":") 446 | if len(toParts) < 2 || strings.ToUpper(strings.TrimSpace(toParts[0])) != "TO" { 447 | s.Out(Codes.FailInvalidAddress) 448 | return 449 | } 450 | 451 | /* 452 | TODO 453 | Servers MUST be prepared to encounter a list of source 454 | routes in the forward-path, but they SHOULD ignore the routes or MAY 455 | decline to support the relaying they imply. 456 | */ 457 | // must be implemented - RFC5321 458 | if strings.ToLower(toParts[1]) == "" { 459 | toParts[1] = "" 460 | } 461 | 462 | rcpt, err := parseAddress(toParts[1]) 463 | if err != nil { 464 | s.Out(Codes.FailInvalidAddress) 465 | return 466 | } 467 | 468 | // check valid recipient if this email comes from outside 469 | err = s.srv.RecipientChecker(s.peer, rcpt) 470 | if err != nil { 471 | if err == ErrorRecipientNotFound { 472 | s.Out(Codes.FailMailboxDoesntExist) 473 | return 474 | } 475 | if err == ErrorRecipientsMailboxFull { 476 | s.Out(Codes.FailMailboxFull) 477 | return 478 | } 479 | s.Out(Codes.FailAccessDenied) 480 | return 481 | } 482 | 483 | // extensions size 484 | if len(args) > 0 { 485 | for _, ext := range args { 486 | extValue := strings.Split(ext, "=") 487 | if len(extValue) != 2 { 488 | s.Out(Codes.FailInvalidAddress) 489 | return 490 | } 491 | switch strings.ToUpper(extValue[0]) { 492 | case "RRVS": 493 | // https://tools.ietf.org/html/rfc7293 494 | since, err := time.Parse(time.RFC3339, extValue[1]) 495 | s.log.Printf("INFO: client requested Require-Recipient-Valid-Since check: %#v %#v\n", since, err) 496 | default: 497 | s.Out("555") 498 | s.envelope.MailFrom = nil 499 | } 500 | } 501 | } 502 | 503 | // Add to recipients 504 | err = s.envelope.AddRecipient(rcpt) 505 | if err != nil { 506 | s.Out(err.Error()) 507 | return 508 | } 509 | 510 | // Change state 511 | switch s.state { 512 | case sessionStateGotMail: 513 | s.state = sessionStateReadyForData 514 | case sessionStateInit: 515 | s.state = sessionStateGotRcpt 516 | case sessionStateGotRcpt, sessionStateReadyForData: 517 | default: 518 | s.state = sessionStateAborted 519 | s.Out(Codes.FailBadSequence) 520 | return 521 | } 522 | s.Out(Codes.SuccessRcptCmd) 523 | } 524 | 525 | func handleVrfy(s *session, _ *command) { 526 | /* 527 | https://tools.ietf.org/html/rfc5336 528 | 529 | For the VRFY command, the string is a user name or a user name and 530 | domain (see below). If a normal (i.e., 250) response is returned, 531 | the response MAY include the full name of the user and MUST include 532 | the mailbox of the user. It MUST be in either of the following 533 | forms: 534 | 535 | User Name 536 | local-part@domain 537 | 538 | */ 539 | s.vrfyCount++ 540 | s.Out("252 send some mail, i'll try my best") 541 | return 542 | } 543 | 544 | func handleData(s *session, cmd *command) { 545 | if s.bodyType == "BINARYMIME" { 546 | /* 547 | https://tools.ietf.org/html/rfc3030 548 | BINARYMIME cannot be used with the DATA command. If a DATA command 549 | is issued after a MAIL command containing the body-value of 550 | "BINARYMIME", a 503 "Bad sequence of commands" response MUST be sent. 551 | The resulting state from this error condition is indeterminate and 552 | the transaction MUST be reset with the RSET command. 553 | */ 554 | s.Out(Codes.FailBadSequence) 555 | s.state = sessionStateAborted 556 | return 557 | } 558 | 559 | // envelope is ready for data 560 | if err := s.envelope.BeginData(); err != nil { 561 | s.Out(err.Error()) 562 | return 563 | } 564 | 565 | // check if we are ready for data 566 | if s.state == sessionStateReadyForData { 567 | s.Out(Codes.SuccessDataCmd) 568 | s.state = sessionStateGettingData 569 | } else { 570 | s.Out(Codes.FailBadSequence) 571 | s.state = sessionStateAborted 572 | return 573 | } 574 | 575 | // set data input time limit 576 | s.conn.SetReadDeadline(time.Now().Add(DefaultLimits.MsgInput)) 577 | 578 | // TODO https://tools.ietf.org/html/rfc5321#section-4.5.3.1.6 579 | // read data, stop on EOF or reaching maximum sizes 580 | var size int64 581 | for size < s.srv.Limits.MsgSize { 582 | line, err := s.bufio.ReadString('\n') 583 | if err != nil { 584 | s.Out(fmt.Sprintf(Codes.FailReadErrorDataCmd, err)) 585 | s.state = sessionStateAborted 586 | return 587 | } 588 | line = strings.TrimSpace(line) 589 | if line == "." { 590 | break 591 | } 592 | size += int64(len(line)) 593 | s.envelope.WriteString(line) 594 | s.envelope.Write([]byte("\r\n")) 595 | } 596 | 597 | // reading ended by reaching maximum size 598 | if size > s.srv.Limits.MsgSize { 599 | s.Out(Codes.FailTooBig) 600 | return 601 | } 602 | 603 | // add received header 604 | /* 605 | When forwarding a message into or out of the Internet environment, a 606 | gateway MUST prepend a Received: line, but it MUST NOT alter in any 607 | way a Received: line that is already in the header section. 608 | */ 609 | s.envelope.headers["Received"] = string(s.ReceivedHeader()) 610 | 611 | // add Message-ID, is user is aut 612 | if s.peer.Authenticated { 613 | s.envelope.headers["Message-ID"] = fmt.Sprintf("Message-ID: <%d.%s@%s>\r\n", time.Now().Unix(), s.id, s.peer.ServerName) 614 | } 615 | 616 | // data done 617 | s.envelope.Close() 618 | s.state = sessionStateWaitingForQuit 619 | 620 | // add envelope to delivery system 621 | id, err := s.srv.Handler(s.peer, s.envelope) 622 | if err != nil { 623 | s.Out("451 temporary queue error") 624 | } else { 625 | s.Out(fmt.Sprintf("%v %s", Codes.SuccessMessageQueued, id)) 626 | } 627 | 628 | // reset session 629 | s.Reset() 630 | return 631 | } 632 | 633 | // handleRset handle reset commands, reset currents session to beginning and empties the envelope 634 | func handleRset(s *session, _ *command) { 635 | s.envelope.Reset() 636 | s.state = sessionStateInit 637 | s.Out(Codes.SuccessResetCmd) 638 | } 639 | 640 | func handleNoop(s *session, _ *command) { 641 | s.Out(Codes.SuccessNoopCmd) 642 | } 643 | 644 | func handleQuit(s *session, _ *command) { 645 | s.Out(Codes.SuccessQuitCmd) 646 | s.state = sessionStateAborted 647 | s.log.Printf("INFO: quit remote %s, server in %s", s.peer.Addr, time.Since(s.start)) 648 | } 649 | 650 | func handleHelp(s *session, _ *command) { 651 | /* 652 | https://tools.ietf.org/html/rfc821 653 | This command causes the receiver to send helpful information 654 | to the sender of the HELP command. The command may take an 655 | argument (e.g., any command name) and return more specific 656 | information as a response. 657 | */ 658 | s.Out(Codes.SuccessHelpCmd + " CaN yOu HelP Me PLeasE!") 659 | } 660 | func handleBdat(s *session, cmd *command) { 661 | args := cmd.arguments 662 | 663 | if s.state == sessionStateDataDone { 664 | /* 665 | Any BDAT command sent after the BDAT LAST is illegal and 666 | MUST be replied to with a 503 "Bad sequence of commands" reply code. 667 | The state resulting from this error is indeterminate. A RSET command 668 | MUST be sent to clear the transaction before continuing. 669 | */ 670 | s.Out("503 Bad sequence of commands") 671 | return 672 | } 673 | 674 | last := false 675 | if len(args) == 0 { 676 | s.Out(Codes.FailUnrecognizedCmd) // TODO use the right code 677 | return 678 | } 679 | 680 | chunkSize64, err := strconv.ParseInt(args[0], 10, 64) 681 | if err != nil { 682 | s.Out(Codes.FailUnrecognizedCmd) // TODO use the right code 683 | s.badCommandsCount++ 684 | return 685 | } 686 | 687 | if (len(args) > 1 && strings.ToUpper(args[1]) == "LAST") || chunkSize64 == 0 { 688 | last = true 689 | } 690 | 691 | s.log.Printf("INFO: received BDAT command, last: %t, data length: %d", last, chunkSize64) 692 | /* 693 | The message data is sent immediately after the trailing 694 | of the BDAT command line. Once the receiver-SMTP receives the 695 | specified number of octets, it will return a 250 reply code. 696 | 697 | If a failure occurs after a BDAT command is 698 | received, the receiver-SMTP MUST accept and discard the associated 699 | message data before sending the appropriate 5XX or 4XX code. 700 | */ 701 | resp := make([]byte, chunkSize64) 702 | if n, err := s.bufio.Read(resp); err != nil { 703 | s.Out(fmt.Sprintf(Codes.FailReadErrorDataCmd, err)) 704 | s.state = sessionStateAborted 705 | return 706 | } else if int64(n) != chunkSize64 { 707 | s.Out(fmt.Sprintf(Codes.FailReadErrorDataCmd, err)) 708 | s.state = sessionStateAborted 709 | return 710 | } 711 | 712 | n, err := s.envelope.Write(resp) 713 | if int64(n) != chunkSize64 { 714 | s.Out(fmt.Sprintf(Codes.FailReadErrorDataCmd, err)) 715 | s.state = sessionStateAborted 716 | return 717 | } 718 | 719 | if last { 720 | // data done 721 | s.Out(fmt.Sprintf("250 BDAT ok, BDAT finished, %d octets received", s.envelope.data.Len())) 722 | s.envelope.Close() 723 | s.state = sessionStateDataDone 724 | } else { 725 | /* 726 | A 250 response MUST be sent to each successful BDAT data block within 727 | a mail transaction. 728 | */ 729 | s.Out(fmt.Sprintf("250 BDAT ok, %d octets received", chunkSize64)) 730 | } 731 | } 732 | func handleExpn(s *session, _ *command) { 733 | s.Out("252") 734 | } 735 | 736 | // authMechanismValid checks if selected authentication mechanism is available 737 | func (s *session) authMechanismValid(mech string) bool { 738 | mech = strings.ToUpper(mech) 739 | for _, m := range s.srv.authMechanisms { 740 | if mech == m { 741 | return true 742 | } 743 | } 744 | return false 745 | } 746 | 747 | func handleAuth(s *session, cmd *command) { 748 | if !s.tls { 749 | // Don't even allow unsecure authentication 750 | s.Out(Codes.FailEncryptionNeeded) 751 | return 752 | } 753 | 754 | // should not happen, some auth is always allowed 755 | if len(s.srv.authMechanisms) == 0 { 756 | s.Out(Codes.FailCmdNotSupported) 757 | // AUTH with no AUTH enabled counts as a 758 | // bad command. This deals with a few people 759 | // who spam AUTH requests at non-supporting 760 | // servers. 761 | s.badCommandsCount++ 762 | return 763 | } 764 | 765 | // if authenticatedUser is already 766 | if s.peer.Authenticated { 767 | // RFC4954, section 4: After an AUTH 768 | // command has been successfully 769 | // completed, no more AUTH commands 770 | // may be issued in the same session. 771 | s.Out(Codes.FailBadSequence) 772 | return 773 | } 774 | 775 | args := cmd.arguments 776 | if len(args) == 0 { 777 | s.Out(Codes.FailMissingArgument) 778 | return 779 | } 780 | if !s.authMechanismValid(strings.ToUpper(args[0])) { 781 | s.Out(Codes.ErrorCmdParamNotImplemented) 782 | return 783 | } 784 | 785 | switch strings.ToUpper(args[0]) { 786 | case "PLAIN": 787 | s.handlePlainAuth(cmd) 788 | case "LOGIN": 789 | s.handleLoginAuth(cmd) 790 | default: 791 | s.Out(Codes.ErrorCmdParamNotImplemented) 792 | return 793 | } 794 | } 795 | 796 | func (s *session) ReceivedHeader() []byte { 797 | /* 798 | "Received:" header fields of messages originating from other 799 | environments may not conform exactly to this specification. However, 800 | the most important use of Received: lines is for debugging mail 801 | faults, and this debugging can be severely hampered by well-meaning 802 | gateways that try to "fix" a Received: line. As another consequence 803 | of trace header fields arising in non-SMTP environments, receiving 804 | systems MUST NOT reject mail based on the format of a trace header 805 | field and SHOULD be extremely robust in the light of unexpected 806 | information or formats in those header fields. 807 | 808 | The gateway SHOULD indicate the environment and protocol in the "via" 809 | clauses of Received header field(s) that it supplies. 810 | */ 811 | remoteIP := strings.Split(s.conn.RemoteAddr().String(), ":")[0] 812 | remotePort := strings.Split(s.conn.RemoteAddr().String(), ":")[1] 813 | remoteHost := "no reverse" 814 | if remoteHosts, err := net.LookupAddr(remoteIP); err == nil { 815 | remoteHost = remoteHosts[0] 816 | } 817 | localIP := strings.Split(s.conn.LocalAddr().String(), ":")[0] 818 | localHost := "no reverse" 819 | localHosts, err := net.LookupAddr(localIP) 820 | if err == nil { 821 | localHost = localHosts[0] 822 | } 823 | 824 | receivedHeader := bytes.NewBufferString("Received: from ") 825 | 826 | // authenticated 827 | auth := "" 828 | if s.peer.Authenticated { 829 | auth = " authenticated as " + s.peer.Username 830 | } 831 | 832 | // host and IP 833 | receivedHeader.WriteString(fmt.Sprintf("%s (%s:%s %s)", remoteHost, remoteIP, remotePort, auth)) 834 | 835 | // TLS 836 | if s.tls { 837 | receivedHeader.WriteString(tlsInfo(&s.tlsState)) 838 | } 839 | 840 | // local 841 | receivedHeader.WriteString(fmt.Sprintf(" by %s (%s)", localIP, localHost)) 842 | 843 | // proto 844 | if s.tls { 845 | receivedHeader.WriteString(" with ESMTPS; ") 846 | } else { 847 | receivedHeader.WriteString(" with SMTP; ") 848 | } 849 | 850 | // mamail version 851 | receivedHeader.WriteString("gomstp(0.0.1); id " + s.id) 852 | 853 | // timestamp 854 | receivedHeader.WriteString("; " + time.Now().Format(time.RFC1123) + "\r\n") 855 | 856 | return wrap(receivedHeader.Bytes()) 857 | } 858 | 859 | func (s *session) Reject() { 860 | s.Out("421 Too busy. Try again later.") 861 | s.state = sessionStateAborted 862 | return 863 | } 864 | 865 | var handlers = []func(s *session, cmd *command){ 866 | handleHelo, 867 | handleEhlo, 868 | handleQuit, 869 | handleRset, 870 | handleNoop, 871 | handleMail, 872 | handleRcpt, 873 | handleData, 874 | handleStartTLS, 875 | handleVrfy, 876 | handleExpn, 877 | handleHelp, 878 | handleAuth, 879 | handleBdat, 880 | } 881 | 882 | // http://www.rfc-base.org/txt/rfc-4408.txt 883 | func checkHost(ip net.IPAddr, domain string, sender string) bool { 884 | 885 | return false 886 | } 887 | -------------------------------------------------------------------------------- /session_test.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "net/mail" 8 | "net/smtp" 9 | "os" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "strings" 14 | ) 15 | 16 | // dummyMailHandler dumps the email to stdout upon receiving it 17 | func dummyHandle(peer *Peer, envelope *Envelope) (id string, err error) { 18 | fmt.Printf("Dummy mail\nFrom: %v\nTo: %v\nMail:\n%v\n", envelope.MailFrom, envelope.MailTo, envelope.Mail) 19 | return "x", nil 20 | } 21 | 22 | // dummyValidator always returns nil as if the recipient was alright 23 | func dummyChecker(peer *Peer, mail *mail.Address) error { 24 | return nil 25 | } 26 | 27 | func init() { 28 | srv, err := NewServer(":4344", log.New(os.Stdout, "", log.LstdFlags)) 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | srv.Handler = dummyHandle 34 | srv.RecipientChecker = dummyChecker 35 | srv.Hostname = "test.com" 36 | 37 | go func() { 38 | err := srv.ListenAndServe() 39 | if err != nil { 40 | panic(err) 41 | } 42 | }() 43 | 44 | //cert, err := tls.LoadX509KeyPair("./cert.pem", "./key.pem") 45 | //config := &tls.Config{Certificates: []tls.Certificate{cert}} 46 | //srv2, err := NewServer(":4345", log.New(os.Stdout, "", log.LstdFlags)) 47 | //if err != nil { 48 | // panic(err) 49 | //} 50 | // 51 | //srv2.Handler = dummyHandle 52 | //srv2.RecipientChecker = dummyChecker 53 | //srv2.Hostname = "securetest.com" 54 | //srv2.TLSConfig = config 55 | //srv2.TLSOnly = true 56 | // 57 | //go func() { 58 | // err := srv2.ListenAndServe() 59 | // if err != nil { 60 | // panic(err) 61 | // } 62 | //}() 63 | } 64 | 65 | func TestSession_ExtensionBDAT(t *testing.T) { 66 | conn, err := smtp.Dial("localhost:4344") 67 | if err != nil { 68 | panic(err) 69 | } 70 | 71 | err = conn.Hello("it's me") 72 | assert.NoError(t, err) 73 | 74 | err = conn.Mail("pele@example.com") 75 | assert.NoError(t, err) 76 | 77 | err = conn.Rcpt("admin@admin.ws") 78 | assert.NoError(t, err) 79 | 80 | ok, _ := conn.Extension("CHUNKING") 81 | assert.True(t, ok, "CHUNKING extension is not supported but should be") 82 | 83 | writer := conn.Text.W 84 | data := []byte("From: pele@example.com\n" + 85 | "To: admin@admin.ws\n" + 86 | "Subject: Hello there\n\n" + 87 | "Hello!!!") 88 | data2 := []byte("it's me\n") 89 | writer.Write([]byte(fmt.Sprintf("BDAT %d\r\n", len(data)))) 90 | writer.Write(data) 91 | writer.Flush() 92 | resp, err := conn.Text.ReadLine() 93 | assert.NoError(t, err, "didn't receive response to BDAT command") 94 | assert.True(t, strings.Contains(resp, "250"), "server sent other code than 250 as response to valid BDAT command") 95 | 96 | writer.Write([]byte(fmt.Sprintf("BDAT %d LAST\r\n", len(data2)))) 97 | writer.Write(data2) 98 | writer.Flush() 99 | resp, err = conn.Text.ReadLine() 100 | assert.NoError(t, err, "didn't receive response to BDAT command") 101 | assert.True(t, strings.Contains(resp, "250"), "server sent other code than 250 as response to valid BDAT command") 102 | } 103 | 104 | func TestSession_ExtensionSTARTTLS(t *testing.T) { 105 | _, err := smtp.Dial("localhost:4344") 106 | if err != nil { 107 | panic(err) 108 | } 109 | } 110 | 111 | func TestSession_ExtensionHELP(t *testing.T) { 112 | conn, err := net.Dial("tcp", "localhost:4344") 113 | if err != nil { 114 | panic(err) 115 | } 116 | 117 | x := make([]byte, 1000) 118 | conn.Read(x) 119 | 120 | _, err = conn.Write([]byte("EHLO it's me\r\n")) 121 | assert.NoError(t, err, "error when sending HELLO") 122 | conn.Read(x) 123 | 124 | _, err = conn.Write([]byte("HELP\r\n")) 125 | assert.NoError(t, err, "error when sending PIPELINED commands") 126 | conn.Read(x) 127 | 128 | conn.Close() 129 | } 130 | 131 | func TestSession_ExtensionPIPELINING(t *testing.T) { 132 | conn, err := net.Dial("tcp", "localhost:4344") 133 | if err != nil { 134 | panic(err) 135 | } 136 | 137 | x := make([]byte, 1000) 138 | conn.Read(x) 139 | 140 | _, err = conn.Write([]byte("EHLO it's me\r\n")) 141 | assert.NoError(t, err, "error when sending HELLO") 142 | 143 | conn.Read(x) 144 | 145 | _, err = conn.Write([]byte("MAIL FROM:\r\nRCPT TO:\r\nRCPT TO:\r\n")) 146 | assert.NoError(t, err, "error when sending PIPELINED commands") 147 | conn.Read(x) 148 | conn.Read(x) 149 | conn.Read(x) 150 | conn.Close() 151 | } 152 | 153 | func TestSession_ExtensionSMTPUTF8(t *testing.T) { 154 | conn, err := smtp.Dial("localhost:4344") 155 | if err != nil { 156 | panic(err) 157 | } 158 | 159 | err = conn.Hello("it's me") 160 | assert.NoError(t, err, "error when sending HELLO") 161 | 162 | err = conn.Mail("Pelé@example.com") 163 | assert.NoError(t, err, "error when sending UTF-8 MAIL") 164 | 165 | err = conn.Rcpt("admin@📙.ws") 166 | assert.NoError(t, err, "error when sending UTF-8 RCPT") 167 | 168 | err = conn.Rcpt("测试@测试.测试") 169 | assert.NoError(t, err, "error when sending UTF-8 RCPT") 170 | 171 | wr, err := conn.Data() 172 | assert.NoError(t, err, "error when sending DATA") 173 | 174 | written, err := wr.Write([]byte("Hello!")) 175 | assert.NoError(t, err, "error when sending message") 176 | assert.Equal(t, written, 6, "message sent only partialy") 177 | 178 | err = wr.Close() 179 | assert.NoError(t, err, "error when ending sending message") 180 | 181 | err = conn.Close() 182 | assert.NoError(t, err, "error when ending SMTP conversation") 183 | } 184 | 185 | func TestSession_Postmaster(t *testing.T) { 186 | conn, err := smtp.Dial("localhost:4344") 187 | if err != nil { 188 | panic(err) 189 | } 190 | 191 | err = conn.Hello("it's me") 192 | assert.NoError(t, err, "error when sending HELLO") 193 | 194 | err = conn.Mail("test@gmail.com") 195 | assert.NoError(t, err, "error when sending MAIL TO:postmaster") 196 | 197 | err = conn.Rcpt("postmaster") 198 | assert.NoError(t, err, "error when sending MAIL TO:postmaster") 199 | 200 | err = conn.Close() 201 | assert.NoError(t, err, "error when ending SMTP conversation") 202 | } 203 | 204 | func TestSession_Serve(t *testing.T) { 205 | conn, err := smtp.Dial("localhost:4344") 206 | if err != nil { 207 | panic(err) 208 | } 209 | 210 | err = conn.Hello("it's me") 211 | assert.NoError(t, err, "error when sending hello") 212 | 213 | err = conn.Quit() 214 | assert.NoError(t, err, "error when quiting the connection") 215 | 216 | conn, err = smtp.Dial("localhost:4344") 217 | if err != nil { 218 | panic(err) 219 | } 220 | 221 | err = conn.Hello("it's me second time") 222 | assert.NoError(t, err, "error when sending HELLO") 223 | 224 | err = conn.Mail("test@tsadasdasdasdsadsadest.te") 225 | assert.NoError(t, err, "error when sending MAIL") 226 | 227 | err = conn.Rcpt("test@other.te") 228 | assert.NoError(t, err, "error when sending RCPT") 229 | 230 | if err != nil { 231 | wr, err := conn.Data() 232 | assert.NoError(t, err, "error when sending DATA") 233 | 234 | written, err := wr.Write([]byte("Hello!")) 235 | assert.NoError(t, err, "error when sending message") 236 | assert.Equal(t, written, 6, "message sent only partialy") 237 | } 238 | 239 | err = conn.Quit() 240 | assert.NoError(t, err, "error when sending RCPT") 241 | } 242 | 243 | func TestSession_handleEhlo(t *testing.T) { 244 | conn, err := smtp.Dial("localhost:4344") 245 | if err != nil { 246 | panic(err) 247 | } 248 | 249 | err = conn.Hello("it's me") 250 | assert.NoError(t, err, "error when sending hello") 251 | 252 | has, _ := conn.Extension("PIPELINING") 253 | assert.True(t, has, "gosmtp should support PIPELINING") 254 | 255 | has, _ = conn.Extension("8BITMIME") 256 | assert.True(t, has, "gosmtp should support 8BITMIME") 257 | 258 | has, _ = conn.Extension("CHUNKING") 259 | assert.True(t, has, "gosmtp should support CHUNKING") 260 | 261 | has, _ = conn.Extension("BINARYMIME") 262 | assert.True(t, has, "gosmtp should support BINARYMIME") 263 | 264 | has, _ = conn.Extension("SMTPUTF8") 265 | assert.True(t, has, "gosmtp should support SMTPUTF8") 266 | 267 | has, _ = conn.Extension("HELP") 268 | assert.True(t, has, "gosmtp should support HELP") 269 | 270 | has, _ = conn.Extension("SIZE") 271 | assert.True(t, has, "gosmtp should support SIZE") 272 | } 273 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package gosmtp 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/signalsciences/tlstext" 10 | ) 11 | 12 | const ( 13 | maxCommandLineLength = 512 14 | ) 15 | 16 | func tlsVersionString(conn *tls.ConnectionState) string { 17 | return tlstext.VersionFromConnection(conn) 18 | } 19 | 20 | func tlsCiherSuiteString(conn *tls.ConnectionState) string { 21 | return tlstext.CipherSuiteFromConnection(conn) 22 | } 23 | 24 | func tlsInfo(conn *tls.ConnectionState) string { 25 | return fmt.Sprintf("(using %s with cipher %s)", tlsVersionString(conn), tlsCiherSuiteString(conn)) 26 | } 27 | 28 | // removeBrackets removes trailing and ending brackets ( -> string) 29 | func removeBrackets(s string) string { 30 | if strings.HasPrefix(s, "<") { 31 | s = s[1:] 32 | } 33 | if strings.HasSuffix(s, ">") { 34 | s = s[0 : len(s)-1] 35 | } 36 | return s 37 | } 38 | 39 | func limitedLineSplitter(data []byte, atEOF bool) (advance int, token []byte, err error) { 40 | dropCR := func(data []byte) []byte { 41 | if len(data) > 0 && data[len(data)-1] == '\r' { 42 | return data[0 : len(data)-1] 43 | } 44 | return data 45 | } 46 | min := func(a, b int) int { 47 | if a < b { 48 | return a 49 | } 50 | return b 51 | } 52 | if atEOF && len(data) == 0 { 53 | return 0, nil, nil 54 | } 55 | if i := bytes.IndexByte(data, '\n'); i >= 0 && i < maxCommandLineLength { 56 | return i + 1, dropCR(data[0:i]), nil 57 | } else if i >= 0 { 58 | l := min(len(data), maxCommandLineLength) 59 | return l + 1, dropCR(data[0:l]), nil 60 | } 61 | // If we're at EOF, we have a final, non-terminated line. Return it. 62 | if atEOF { 63 | return len(data), dropCR(data), nil 64 | } 65 | return 0, nil, nil 66 | } 67 | 68 | func stringInSlice(a string, list []string) bool { 69 | for _, b := range list { 70 | if b == a { 71 | return true 72 | } 73 | } 74 | return false 75 | } 76 | 77 | // DummyMailStore for testing purpouses, doesn't handle the mail 78 | type DummyMailStore struct{} 79 | 80 | // Handle discards the email 81 | func (dms *DummyMailStore) Handle(envelope *Envelope, user string) (id string, err error) { 82 | return "QUEUED_MAIL_ID", nil 83 | } 84 | 85 | // Wrap a byte slice paragraph for use in SMTP header 86 | func wrap(sl []byte) []byte { 87 | length := 0 88 | for i := 0; i < len(sl); i++ { 89 | if length > 76 && sl[i] == ' ' { 90 | sl = append(sl, 0, 0) 91 | copy(sl[i+2:], sl[i:]) 92 | sl[i] = '\r' 93 | sl[i+1] = '\n' 94 | sl[i+2] = '\t' 95 | i += 2 96 | length = 0 97 | } 98 | if sl[i] == '\n' { 99 | length = 0 100 | } 101 | length++ 102 | } 103 | return sl 104 | } 105 | --------------------------------------------------------------------------------