parent
18bb710c40
commit
d1e88d2564
|
@ -0,0 +1,12 @@
|
|||
zangtumb
|
||||
mail/*
|
||||
certs/*
|
||||
recipients.conf
|
||||
binaries
|
||||
binaries/*
|
||||
build.sh
|
||||
.vscode
|
||||
.vscode/*
|
||||
build.sh
|
||||
binaries
|
||||
binaries/*
|
|
@ -0,0 +1,14 @@
|
|||
Copyright (C) 2020 loweel@keinpfusch.net
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
@ -0,0 +1,120 @@
|
|||
Futuristic SMTP INBOUND-only server for home usage.
|
||||
Inspired by Marinetti's RFC (AKA _Manifesto of Futurism_).
|
||||
|
||||
It only serves a precise list of email address. No aliases.
|
||||
|
||||
Everything else will be apparently accepted, and then discarded.
|
||||
So that, spammers will waste their time (and money).
|
||||
|
||||
# REQUIREMENTS:
|
||||
|
||||
- Golang version >= 1.13
|
||||
- git
|
||||
|
||||
# INSTALLATION
|
||||
|
||||
First download the code into the folder you want to use with Golang
|
||||
|
||||
```
|
||||
git clone https://git.keinpfusch.net/loweel/zangtumb.git
|
||||
go build -mod=vendor
|
||||
|
||||
./zangtumb to start the daemon , after setting the environment strings.
|
||||
|
||||
```
|
||||
|
||||
|
||||
# CONFIGURATION
|
||||
|
||||
zangtumb is designed be easy to dockerize.
|
||||
|
||||
A reference pseudo-dockerfile could be:
|
||||
|
||||
|
||||
|
||||
```dockerfile
|
||||
|
||||
FROM debian:stable-slim
|
||||
##MAIN
|
||||
ENV KEYFILE "/certs/"mydomain.key"
|
||||
ENV CERTFILE "/certs/mydomain.crt"
|
||||
ENV DOMAINNAME "mydomain.tld"
|
||||
ENV LISTEN ":5025"
|
||||
|
||||
|
||||
##SESSION
|
||||
ENV RECIPIENTS "recipients.conf"
|
||||
ENV MAILFOLDER "/zangmail"
|
||||
|
||||
##MAIN
|
||||
ENV USETLS="true"
|
||||
## HERE WE GO
|
||||
|
||||
RUN useradd -ms /bin/bash zangtumb
|
||||
RUN mkdir -p /opt/zangtumb
|
||||
RUN mkdir -p /zangmail
|
||||
COPY . /opt/zangtumb/
|
||||
|
||||
RUN chown -R zangtumb:zangtumb /opt/zangtumb
|
||||
RUN chown -R zangtumb:zangtumb /zangmail
|
||||
EXPOSE 5025
|
||||
|
||||
USER zangtumb
|
||||
WORKDIR /opt/zangtumb
|
||||
ENTRYPOINT ["/opt/zangtumb/zangtumb"]
|
||||
|
||||
```
|
||||
|
||||
everything is configured using ENV strings , as follows
|
||||
|
||||
| ENV STRING | Example value | Meaning |
|
||||
| ---------- | --------------------- | ------------------------------------------------------------ |
|
||||
| KEYFILE | "/certs/mydomain.key" | Path for private key. Only needed when using TLS. Which means, well... it's your email. So you don't want to send it in clear, isn't it? |
|
||||
| CERTFILE | "/certs/mydomain.crt" | Path for certificate. Only needed when using TLS. Which means, well... it's your email. So you don't want to send it in clear, isn't it? |
|
||||
| DOMAINNAME | "mydomain.tld" | will declare this value on the banner. No impact on recipients. |
|
||||
| LISTEN | ":5025" | Address to listen in golang format. This example will listen to port 5025 on all interfaces. You may specify a specific interface like "1.2.3.4:5025" |
|
||||
| RECIPIENTS | "recipients.conf" | File containing a list with email to serve. One mail address per line. Please notice, that pippo@pluto.com and pippo@paperino.com will end in the same mailbox, "pippo". |
|
||||
| MAILFOLDER | "/zangmail" | Root of mailfolder. Mail is stored in the default dovecot Maildir format, meaning in the example "/zangmail/%u/Maildir" . |
|
||||
| USETLS | "true" | Whether to force all to use TLS or not. yes. Do it. |
|
||||
|
||||
|
||||
|
||||
That's it.
|
||||
|
||||
# FAQ
|
||||
|
||||
- _This TLS behavior is violating RFC 2487_
|
||||
- To give a shit of RFCs is a de facto standard. It works, and no spammer will ever buy a certificate per each spambot.
|
||||
|
||||
|
||||
|
||||
- _The minimal amount of recipients by RFC 5321 is 100. You reduced it._
|
||||
- Yes. The reason is, we allow the ones we need. No more. This server is supposed to run inside a Raspberry, if needed. Call the RFC police, if you don't like.
|
||||
|
||||
|
||||
|
||||
- _The example dockerfile is way too big. Why no multistage?_
|
||||
- This is because is an example. An example must be easy to understand. An example must be _simple_. Even you should be able to understand it. Well.... ok. Let's say, even _Bob_ should.
|
||||
|
||||
|
||||
|
||||
- _Why don't you use opensmtpd?_
|
||||
- To make this server took less than dockerizing opensmtpd in a decent way.
|
||||
|
||||
|
||||
|
||||
- _Why don't you use postfix/sendmail/qmail/courier_
|
||||
- I serve 4 mailboxes in total. Why should I deploy all that complexity? Complexity != security.
|
||||
|
||||
|
||||
|
||||
- _Silently discarrding email after pretending you've accepted is not nice. Perhaps, this will make your server to look like an open relay._
|
||||
- Unfortunately, English cannot translate the correct answer, which is "esticazzi non ce lo scriviamo?". So I can't properly answer you.
|
||||
|
||||
|
||||
|
||||
- _This golang code is not idiomatic. And there is no graphene, no quantum computing, no UI/UX and no horizontal scaling of Internet of Things with Artificial Intelligence of Big Data._
|
||||
- Please, bring me a Frappuccino.
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The Backend implements SMTP server methods.
|
||||
type Backend struct {
|
||||
MailBaseFolder string
|
||||
ValidRecipientsFile string
|
||||
ValidRecipientsMap map[string]struct{}
|
||||
MaxRecipients int
|
||||
}
|
||||
|
||||
var SmtpBackend *Backend
|
||||
|
||||
// Load Valid Recipients
|
||||
func (bkd *Backend) LoadValidRecipients() error {
|
||||
bkd.MaxRecipients = 0
|
||||
var void struct{}
|
||||
file, err := os.Open(bkd.ValidRecipientsFile)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
s := strings.Trim(scanner.Text(), " ")
|
||||
bkd.ValidRecipientsMap[s] = void
|
||||
log.Printf("RCPT: <%s>", s)
|
||||
bkd.MaxRecipients++
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// Checks if Mail is a valid recipient
|
||||
func (bkd *Backend) CheckValidRcpt(to string) bool {
|
||||
|
||||
_, isValid := bkd.ValidRecipientsMap[to]
|
||||
|
||||
return isValid
|
||||
|
||||
}
|
||||
|
||||
func init() {
|
||||
SmtpBackend = new(Backend)
|
||||
SmtpBackend.ValidRecipientsMap = make(map[string]struct{})
|
||||
SmtpBackend.ValidRecipientsFile = os.Getenv("RECIPIENTS")
|
||||
if SmtpBackend.ValidRecipientsFile == "" {
|
||||
SmtpBackend.ValidRecipientsFile = "./recipients.conf"
|
||||
}
|
||||
|
||||
SmtpBackend.MailBaseFolder = os.Getenv("MAILFOLDER")
|
||||
if SmtpBackend.MailBaseFolder == "" {
|
||||
SmtpBackend.MailBaseFolder = "./mail"
|
||||
}
|
||||
if err := SmtpBackend.LoadValidRecipients(); err != nil {
|
||||
log.Println("Failed to load Recipients", err)
|
||||
} else {
|
||||
log.Println("Recipients Loaded")
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
module zangtumb
|
||||
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/amalfra/maildir v0.0.7
|
||||
github.com/emersion/go-smtp v0.12.1 // indirect
|
||||
)
|
|
@ -0,0 +1,6 @@
|
|||
github.com/amalfra/maildir v0.0.7 h1:quK6vVGmjqP1zbeo9g7jtx37htGoYrKKlSpcDcplD04=
|
||||
github.com/amalfra/maildir v0.0.7/go.mod h1:+ynYowCpUHTWebUhg3Sb6Jp2dq+SUSWLTSLIf7Vy+ak=
|
||||
github.com/emersion/go-sasl v0.0.0-20190817083125-240c8404624e h1:ba7YsgX5OV8FjGi5ZWml8Jng6oBrJAb3ahqWMJ5Ce8Q=
|
||||
github.com/emersion/go-sasl v0.0.0-20190817083125-240c8404624e/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
|
||||
github.com/emersion/go-smtp v0.12.1 h1:1R8BDqrR2HhlGwgFYcOi+BVTvK1bMjAB65QcVpJ5sNA=
|
||||
github.com/emersion/go-smtp v0.12.1/go.mod h1:SD9V/xa4ndMw77lR3Mf7htkp8RBNYuPh9UeuBs9tpUQ=
|
|
@ -0,0 +1,30 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net"
|
||||
)
|
||||
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) {
|
||||
SmtpSession := new(Session)
|
||||
|
||||
log.Printf("Received mail from %s for %q from %s", from, to, origin.String())
|
||||
log.Println(" Recipients: ", to)
|
||||
|
||||
for _, rcptTo := range to {
|
||||
SmtpSession.Reset()
|
||||
|
||||
if rcptErr := SmtpSession.Rcpt(rcptTo); rcptErr == nil {
|
||||
SmtpSession.MailFrom = from
|
||||
if dataErr := SmtpSession.Data(bytes.NewReader(data)); dataErr != nil {
|
||||
log.Println("Problem Saving Message: ", dataErr.Error())
|
||||
}
|
||||
log.Println("Session is: ", SmtpSession)
|
||||
} else {
|
||||
log.Println(rcptErr)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
joe@example.org
|
||||
alice@whatever.com
|
||||
bob@subgenius.org
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/bash
|
||||
|
||||
##MAIN
|
||||
export KEYFILE="certs/server.key"
|
||||
export CERTFILE="certs/server.crt"
|
||||
export DOMAINNAME="testDomain"
|
||||
export LISTEN=":1025"
|
||||
|
||||
|
||||
##SESSION
|
||||
export RECIPIENTS="./recipients.conf"
|
||||
export MAILFOLDER="mail"
|
||||
|
||||
##MAIN
|
||||
export USETLS="false"
|
||||
|
||||
##RUN
|
||||
|
||||
./zangtumb
|
|
@ -0,0 +1,72 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/amalfra/maildir"
|
||||
)
|
||||
|
||||
// A Session is returned after successful login.
|
||||
type Session struct {
|
||||
MailFrom string
|
||||
RcptTo string
|
||||
LocalUser string
|
||||
MailDomain string
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func (s *Session) Rcpt(to string) error {
|
||||
log.Println("Rcpt to:", to)
|
||||
if !SmtpBackend.CheckValidRcpt(to) {
|
||||
time.Sleep(10 * time.Second)
|
||||
return errors.New("YU NO MAIL!!! WE NU RELAY")
|
||||
}
|
||||
s.RcptTo = to
|
||||
if strings.Contains(to, "@") {
|
||||
rcptArray := strings.Split(to, "@")
|
||||
s.MailDomain = rcptArray[1]
|
||||
s.LocalUser = rcptArray[0]
|
||||
s.Valid = true
|
||||
} else {
|
||||
s.Valid = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Data(r io.Reader) error {
|
||||
log.Println("Start Saving Message")
|
||||
if !s.Valid {
|
||||
log.Println("Message Was Not Valid: ", s)
|
||||
return nil
|
||||
}
|
||||
if b, err := ioutil.ReadAll(r); err != nil {
|
||||
return err
|
||||
} else {
|
||||
//using the default Maildir structure
|
||||
mad := fmt.Sprintf("%s/%s/Maildir", SmtpBackend.MailBaseFolder, s.LocalUser)
|
||||
log.Println("Maildir is:", mad)
|
||||
myMaildir := maildir.NewMaildir(mad)
|
||||
_, errMd := myMaildir.Add(string(b))
|
||||
if errMd != nil {
|
||||
log.Println("Error Saving mail ")
|
||||
return errMd
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Reset() {
|
||||
s.LocalUser = ""
|
||||
s.MailDomain = ""
|
||||
s.MailFrom = ""
|
||||
s.RcptTo = ""
|
||||
s.Valid = false
|
||||
}
|
|
@ -0,0 +1,756 @@
|
|||
// Package smtpd implements a basic SMTP server.
|
||||
// https://github.com/mhale/smtpd
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Debug `true` enables verbose logging.
|
||||
Debug = false
|
||||
rcptToRE = regexp.MustCompile(`[Tt][Oo]:[ ]*<(.+[@].+)>`) // We don't receive for local users with no domain.
|
||||
mailFromRE = regexp.MustCompile(`[Ff][Rr][Oo][Mm]:[ ]*<(.*)>(\s(.*))?`) // Delivery Status Notifications are sent with "MAIL FROM:<>"
|
||||
mailSizeRE = regexp.MustCompile(`[Ss][Ii][Zz][Ee]=(\d+)`)
|
||||
)
|
||||
|
||||
// Handler function called upon successful receipt of an email.
|
||||
type Handler func(remoteAddr net.Addr, from string, to []string, data []byte)
|
||||
|
||||
// HandlerRcpt function called on RCPT. Return accept status.
|
||||
type HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool
|
||||
|
||||
// AuthHandler function called when a login attempt is performed. Returns true if credentials are correct.
|
||||
type AuthHandler func(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error)
|
||||
|
||||
// ListenAndServe listens on the TCP network address addr
|
||||
// and then calls Serve with handler to handle requests
|
||||
// on incoming connections.
|
||||
func ListenAndServe(addr string, handler Handler, appname string, hostname string, instance *Server) error {
|
||||
srv := instance
|
||||
srv.Addr = addr
|
||||
srv.Handler = handler
|
||||
srv.Appname = appname
|
||||
srv.Hostname = hostname
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
type maxSizeExceededError struct {
|
||||
limit int
|
||||
}
|
||||
|
||||
func maxSizeExceeded(limit int) maxSizeExceededError {
|
||||
return maxSizeExceededError{limit}
|
||||
}
|
||||
|
||||
// Error uses the RFC 5321 response message in preference to RFC 1870.
|
||||
// RFC 3463 defines enhanced status code x.3.4 as "Message too big for system".
|
||||
func (err maxSizeExceededError) Error() string {
|
||||
return fmt.Sprintf("552 5.3.4 Requested mail action aborted: exceeded storage allocation (%d)", err.limit)
|
||||
}
|
||||
|
||||
// LogFunc is a function capable of logging the client-server communication.
|
||||
type LogFunc func(remoteIP, verb, line string)
|
||||
|
||||
// Server is an SMTP server.
|
||||
type Server struct {
|
||||
Addr string // TCP address to listen on, defaults to ":25" (all addresses, port 25) if empty
|
||||
Appname string
|
||||
AuthHandler AuthHandler
|
||||
AuthMechs map[string]bool // Override list of allowed authentication mechanisms. Currently supported: LOGIN, PLAIN, CRAM-MD5. Enabling LOGIN and PLAIN will reduce RFC 4954 compliance.
|
||||
AuthRequired bool // Require authentication for every command except AUTH, EHLO, HELO, NOOP, RSET or QUIT as per RFC 4954. Ignored if AuthHandler is not configured.
|
||||
Handler Handler
|
||||
HandlerRcpt HandlerRcpt
|
||||
Hostname string
|
||||
LogRead LogFunc
|
||||
LogWrite LogFunc
|
||||
MaxSize int // Maximum message size allowed, in bytes
|
||||
MaxRcpt int // Maximum number of recipients
|
||||
Timeout time.Duration
|
||||
TLSConfig *tls.Config
|
||||
TLSListener bool // Listen for incoming TLS connections only (not recommended as it may reduce compatibility). Ignored if TLS is not configured.
|
||||
TLSRequired bool // Require TLS for every command except NOOP, EHLO, STARTTLS, or QUIT as per RFC 3207. Ignored if TLS is not configured.
|
||||
}
|
||||
|
||||
// ConfigureTLS creates a TLS configuration from certificate and key files.
|
||||
func (srv *Server) ConfigureTLS(certFile string, keyFile string) error {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigureTLSWithPassphrase creates a TLS configuration from a certificate,
|
||||
// an encrypted key file and the associated passphrase:
|
||||
func (srv *Server) ConfigureTLSWithPassphrase(
|
||||
certFile string,
|
||||
keyFile string,
|
||||
passphrase string,
|
||||
) error {
|
||||
certPEMBlock, err := ioutil.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEMBlock, err := ioutil.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyDERBlock, _ := pem.Decode(keyPEMBlock)
|
||||
keyPEMDecrypted, err := x509.DecryptPEMBlock(keyDERBlock, []byte(passphrase))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pemBlock pem.Block
|
||||
pemBlock.Type = keyDERBlock.Type
|
||||
pemBlock.Bytes = keyPEMDecrypted
|
||||
keyPEMBlock = pem.EncodeToMemory(&pemBlock)
|
||||
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListenAndServe listens on the TCP network address srv.Addr and then
|
||||
// calls Serve to handle requests on incoming connections. If
|
||||
// srv.Addr is blank, ":25" is used.
|
||||
func (srv *Server) ListenAndServe() error {
|
||||
if srv.Addr == "" {
|
||||
srv.Addr = ":25"
|
||||
}
|
||||
if srv.Appname == "" {
|
||||
srv.Appname = "smtpd"
|
||||
}
|
||||
if srv.Hostname == "" {
|
||||
srv.Hostname, _ = os.Hostname()
|
||||
}
|
||||
if srv.Timeout == 0 {
|
||||
srv.Timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
var ln net.Listener
|
||||
var err error
|
||||
|
||||
// If TLSListener is enabled, listen for TLS connections only.
|
||||
if srv.TLSConfig != nil && srv.TLSListener {
|
||||
ln, err = tls.Listen("tcp", srv.Addr, srv.TLSConfig)
|
||||
} else {
|
||||
ln, err = net.Listen("tcp", srv.Addr)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
|
||||
// Serve creates a new SMTP session after a network connection is established.
|
||||
func (srv *Server) Serve(ln net.Listener) error {
|
||||
defer ln.Close()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Temporary() {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
session := srv.newSession(conn)
|
||||
go session.serve()
|
||||
}
|
||||
}
|
||||
|
||||
type session struct {
|
||||
srv *Server
|
||||
conn net.Conn
|
||||
br *bufio.Reader
|
||||
bw *bufio.Writer
|
||||
remoteIP string // Remote IP address
|
||||
remoteHost string // Remote hostname according to reverse DNS lookup
|
||||
remoteName string // Remote hostname as supplied with EHLO
|
||||
tls bool
|
||||
authenticated bool
|
||||
}
|
||||
|
||||
// Create new session from connection.
|
||||
func (srv *Server) newSession(conn net.Conn) (s *session) {
|
||||
s = &session{
|
||||
srv: srv,
|
||||
conn: conn,
|
||||
br: bufio.NewReader(conn),
|
||||
bw: bufio.NewWriter(conn),
|
||||
}
|
||||
|
||||
// Get remote end info for the Received header.
|
||||
s.remoteIP, _, _ = net.SplitHostPort(s.conn.RemoteAddr().String())
|
||||
names, err := net.LookupAddr(s.remoteIP)
|
||||
if err == nil && len(names) > 0 {
|
||||
s.remoteHost = names[0]
|
||||
} else {
|
||||
s.remoteHost = "unknown"
|
||||
}
|
||||
|
||||
// Set tls = true if TLS is already in use.
|
||||
_, s.tls = s.conn.(*tls.Conn)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Function called to handle connection requests.
|
||||
func (s *session) serve() {
|
||||
defer s.conn.Close()
|
||||
var from string
|
||||
var gotFrom bool
|
||||
var to []string
|
||||
var buffer bytes.Buffer
|
||||
|
||||
// Send banner.
|
||||
s.writef("220 %s %s ESMTP Service ready", s.srv.Hostname, s.srv.Appname)
|
||||
|
||||
loop:
|
||||
for {
|
||||
// Attempt to read a line from the socket.
|
||||
// On timeout, send a timeout message and return from serve().
|
||||
// On error, assume the client has gone away i.e. return from serve().
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.Appname)
|
||||
}
|
||||
break
|
||||
}
|
||||
verb, args := s.parseLine(line)
|
||||
|
||||
switch verb {
|
||||
case "HELO":
|
||||
s.remoteName = args
|
||||
s.writef("250 %s greets %s", s.srv.Hostname, s.remoteName)
|
||||
|
||||
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET, so reset for HELO too.
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "EHLO":
|
||||
s.remoteName = args
|
||||
s.writef(s.makeEHLOResponse())
|
||||
|
||||
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "MAIL":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
|
||||
match := mailFromRE.FindStringSubmatch(args)
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
|
||||
} else {
|
||||
// Validate the SIZE parameter if one was sent.
|
||||
if len(match[2]) > 0 { // A parameter is present
|
||||
sizeMatch := mailSizeRE.FindStringSubmatch(match[3])
|
||||
if sizeMatch == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)")
|
||||
} else {
|
||||
// Enforce the maximum message size if one is set.
|
||||
size, err := strconv.Atoi(sizeMatch[1])
|
||||
if err != nil { // Bad SIZE parameter
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid SIZE parameter)")
|
||||
} else if s.srv.MaxSize > 0 && size > s.srv.MaxSize { // SIZE above maximum size, if set
|
||||
err = maxSizeExceeded(s.srv.MaxSize)
|
||||
s.writef(err.Error())
|
||||
} else { // SIZE ok
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
} else { // No parameters after FROM
|
||||
from = match[1]
|
||||
gotFrom = true
|
||||
s.writef("250 2.1.0 Ok")
|
||||
}
|
||||
}
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "RCPT":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotFrom {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (MAIL required before RCPT)")
|
||||
break
|
||||
}
|
||||
|
||||
match := rcptToRE.FindStringSubmatch(args)
|
||||
if match == nil {
|
||||
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
|
||||
} else {
|
||||
// RFC 5321 specifies 100 minimum recipients
|
||||
// but I give a shit of RFC5321
|
||||
if len(to) == s.srv.MaxRcpt {
|
||||
s.writef("452 4.5.3 Too many recipients, %d > %d ", len(to), s.srv.MaxRcpt)
|
||||
} else {
|
||||
accept := true
|
||||
if s.srv.HandlerRcpt != nil {
|
||||
accept = s.srv.HandlerRcpt(s.conn.RemoteAddr(), from, match[1])
|
||||
}
|
||||
if accept {
|
||||
to = append(to, match[1])
|
||||
s.writef("250 2.1.5 Ok")
|
||||
} else {
|
||||
s.writef("550 5.1.0 Requested action not taken: mailbox unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
case "DATA":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
if s.srv.AuthHandler != nil && s.srv.AuthRequired && !s.authenticated {
|
||||
s.writef("530 5.7.0 Authentication required")
|
||||
break
|
||||
}
|
||||
if !gotFrom || len(to) == 0 {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)")
|
||||
break
|
||||
}
|
||||
|
||||
s.writef("354 Start mail input; end with <CR><LF>.<CR><LF>")
|
||||
|
||||
// Attempt to read message body from the socket.
|
||||
// On timeout, send a timeout message and return from serve().
|
||||
// On net.Error, assume the client has gone away i.e. return from serve().
|
||||
// On other errors, allow the client to try again.
|
||||
data, err := s.readData()
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case net.Error:
|
||||
if err.(net.Error).Timeout() {
|
||||
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.Appname)
|
||||
}
|
||||
break loop
|
||||
case maxSizeExceededError:
|
||||
s.writef(err.Error())
|
||||
continue
|
||||
default:
|
||||
s.writef("451 4.3.0 Requested action aborted: local error in processing")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Create Received header & write message body into buffer.
|
||||
buffer.Reset()
|
||||
buffer.Write(s.makeHeaders(to))
|
||||
buffer.Write(data)
|
||||
s.writef("250 2.0.0 Ok: queued")
|
||||
|
||||
// Pass mail on to handler.
|
||||
if s.srv.Handler != nil {
|
||||
go s.srv.Handler(s.conn.RemoteAddr(), from, to, buffer.Bytes())
|
||||
}
|
||||
|
||||
// Reset for next mail.
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "QUIT":
|
||||
s.writef("221 2.0.0 %s %s ESMTP Service closing transmission channel", s.srv.Hostname, s.srv.Appname)
|
||||
break loop
|
||||
case "RSET":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
s.writef("250 2.0.0 Ok")
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "NOOP":
|
||||
s.writef("250 2.0.0 Ok")
|
||||
case "HELP", "VRFY", "EXPN":
|
||||
// See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes.
|
||||
s.writef("502 5.5.1 Command not implemented")
|
||||
case "STARTTLS":
|
||||
// Parameters are not allowed (RFC 3207 section 4).
|
||||
if args != "" {
|
||||
s.writef("501 5.5.2 Syntax error (no parameters allowed)")
|
||||
break
|
||||
}
|
||||
|
||||
// Handle case where TLS is requested but not configured (and therefore not listed as a service extension).
|
||||
if s.srv.TLSConfig == nil {
|
||||
s.writef("502 5.5.1 Command not implemented")
|
||||
break
|
||||
}
|
||||
|
||||
// Handle case where STARTTLS is received when TLS is already in use.
|
||||
if s.tls {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (TLS already in use)")
|
||||
break
|
||||
}
|
||||
|
||||
s.writef("220 2.0.0 Ready to start TLS")
|
||||
|
||||
// Establish a TLS connection with the client.
|
||||
tlsConn := tls.Server(s.conn, s.srv.TLSConfig)
|
||||
err := tlsConn.Handshake()
|
||||
if err != nil {
|
||||
s.writef("403 4.7.0 TLS handshake failed")
|
||||
break
|
||||
}
|
||||
|
||||
// TLS handshake succeeded, switch to using the TLS connection.
|
||||
s.conn = tlsConn
|
||||
s.br = bufio.NewReader(s.conn)
|
||||
s.bw = bufio.NewWriter(s.conn)
|
||||
s.tls = true
|
||||
|
||||
// RFC 3207 specifies that the server must discard any prior knowledge obtained from the client.
|
||||
s.remoteName = ""
|
||||
from = ""
|
||||
gotFrom = false
|
||||
to = nil
|
||||
buffer.Reset()
|
||||
case "AUTH":
|
||||
if s.srv.TLSConfig != nil && s.srv.TLSRequired && !s.tls {
|
||||
s.writef("530 5.7.0 Must issue a STARTTLS command first")
|
||||
break
|
||||
}
|
||||
// Handle case where AUTH is requested but not configured (and therefore not listed as a service extension).
|
||||
if s.srv.AuthHandler == nil {
|
||||
s.writef("502 5.5.1 Command not implemented")
|
||||
break
|
||||
}
|
||||
|
||||
// Handle case where AUTH is received when already authenticated.
|
||||
if s.authenticated {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (already authenticated for this session)")
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 specifies that AUTH is not permitted during mail transactions.
|
||||
if gotFrom || len(to) > 0 {
|
||||
s.writef("503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)")
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 requires a mechanism parameter.
|
||||
authType, authArgs := s.parseLine(args)
|
||||
if authType == "" {
|
||||
s.writef("501 5.5.4 Malformed AUTH input (argument required)")
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 requires rejecting unsupported authentication mechanisms with a 504 response.
|
||||
allowedAuth := s.authMechs()
|
||||
if allowed, found := allowedAuth[authType]; !found || !allowed {
|
||||
s.writef("504 5.5.4 Unrecognized authentication type")
|
||||
break
|
||||
}
|
||||
|
||||
// RFC 4954 also specifies that ESMTP code 5.5.4 ("Invalid command arguments") should be returned
|
||||
// when attempting to use an unsupported authentication type.
|
||||
// Many servers return 5.7.4 ("Security features not supported") instead.
|
||||
switch authType {
|
||||
case "PLAIN":
|
||||
s.authenticated, err = s.handleAuthPlain(authArgs)
|
||||
case "LOGIN":
|
||||
s.authenticated, err = s.handleAuthLogin(authArgs)
|
||||
case "CRAM-MD5":
|
||||
s.authenticated, err = s.handleAuthCramMD5()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
s.writef("421 4.4.2 %s %s ESMTP Service closing transmission channel after timeout exceeded", s.srv.Hostname, s.srv.Appname)
|
||||
break loop
|
||||
}
|
||||
|
||||
s.writef(err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
if s.authenticated {
|
||||
s.writef("235 2.7.0 Authentication successful")
|
||||
} else {
|
||||
s.writef("535 5.7.8 Authentication credentials invalid")
|
||||
}
|
||||
default:
|
||||
// See RFC 5321 section 4.2.4 for usage of 500 & 502 response codes.
|
||||
s.writef("500 5.5.2 Syntax error, command unrecognized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper function for writing a complete line to the socket.
|
||||
func (s *session) writef(format string, args ...interface{}) error {
|
||||
if s.srv.Timeout > 0 {
|
||||
s.conn.SetWriteDeadline(time.Now().Add(s.srv.Timeout))
|
||||
}
|
||||
|
||||
line := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(s.bw, line+"\r\n")
|
||||
err := s.bw.Flush()
|
||||
|
||||
if Debug {
|
||||
verb := "WROTE"
|
||||
if s.srv.LogWrite != nil {
|
||||
s.srv.LogWrite(s.remoteIP, verb, line)
|
||||
} else {
|
||||
log.Println(s.remoteIP, verb, line)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Read a complete line from the socket.
|
||||
func (s *session) readLine() (string, error) {
|
||||
if s.srv.Timeout > 0 {
|
||||
s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout))
|
||||
}
|
||||
|
||||
line, err := s.br.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
line = strings.TrimSpace(line) // Strip trailing \r\n
|
||||
|
||||
if Debug {
|
||||
verb := "READ"
|
||||
if s.srv.LogRead != nil {
|
||||
s.srv.LogRead(s.remoteIP, verb, line)
|
||||
} else {
|
||||
log.Println(s.remoteIP, verb, line)
|
||||
}
|
||||
}
|
||||
|
||||
return line, err
|
||||
}
|
||||
|
||||
// Parse a line read from the socket.
|
||||
func (s *session) parseLine(line string) (verb string, args string) {
|
||||
if idx := strings.Index(line, " "); idx != -1 {
|
||||
verb = strings.ToUpper(line[:idx])
|
||||
args = strings.TrimSpace(line[idx+1:])
|
||||
} else {
|
||||
verb = strings.ToUpper(line)
|
||||
args = ""
|
||||
}
|
||||
return verb, args
|
||||
}
|
||||
|
||||
// Read the message data following a DATA command.
|
||||
func (s *session) readData() ([]byte, error) {
|
||||
var data []byte
|
||||
for {
|
||||
if s.srv.Timeout > 0 {
|
||||
s.conn.SetReadDeadline(time.Now().Add(s.srv.Timeout))
|
||||
}
|
||||
|
||||
line, err := s.br.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Handle end of data denoted by lone period (\r\n.\r\n)
|
||||
if bytes.Equal(line, []byte(".\r\n")) {
|
||||
break
|
||||
}
|
||||
// Remove leading period (RFC 5321 section 4.5.2)
|
||||
if line[0] == '.' {
|
||||
line = line[1:]
|
||||
}
|
||||
|
||||
// Enforce the maximum message size limit.
|
||||
if s.srv.MaxSize > 0 {
|
||||
if len(data)+len(line) > s.srv.MaxSize {
|
||||
_, _ = s.br.Discard(s.br.Buffered()) // Discard the buffer remnants.
|
||||
return nil, maxSizeExceeded(s.srv.MaxSize)
|
||||
}
|
||||
}
|
||||
|
||||
data = append(data, line...)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Create the Received header to comply with RFC 2821 section 3.8.2.
|
||||
// TODO: Work out what to do with multiple to addresses.
|
||||
func (s *session) makeHeaders(to []string) []byte {
|
||||
var buffer bytes.Buffer
|
||||
now := time.Now().Format("Mon, _2 Jan 2006 15:04:05 -0700 (MST)")
|
||||
buffer.WriteString(fmt.Sprintf("Received: from %s (%s [%s])\r\n", s.remoteName, s.remoteHost, s.remoteIP))
|
||||
buffer.WriteString(fmt.Sprintf(" by %s (%s) with SMTP\r\n", s.srv.Hostname, s.srv.Appname))
|
||||
buffer.WriteString(fmt.Sprintf(" for <%s>; %s\r\n", to[0], now))
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
// Determine allowed authentication mechanisms.
|
||||
// RFC 4954 specifies that plaintext authentication mechanisms such as LOGIN and PLAIN require a TLS connection.
|
||||
// This can be explicitly overridden e.g. setting s.srv.AuthMechs["LOGIN"] = true.
|
||||
func (s *session) authMechs() (mechs map[string]bool) {
|
||||
mechs = map[string]bool{"LOGIN": s.tls, "PLAIN": s.tls, "CRAM-MD5": true}
|
||||
|
||||
for mech := range mechs {
|
||||
allowed, found := s.srv.AuthMechs[mech]
|
||||
if found {
|
||||
mechs[mech] = allowed
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create the greeting string sent in response to an EHLO command.
|
||||
func (s *session) makeEHLOResponse() (response string) {
|
||||
response = fmt.Sprintf("250-%s greets %s\r\n", s.srv.Hostname, s.remoteName)
|
||||
|
||||
// RFC 1870 specifies that "SIZE 0" indicates no maximum size is in force.
|
||||
response += fmt.Sprintf("250-SIZE %d\r\n", s.srv.MaxSize)
|
||||
|
||||
// Only list STARTTLS if TLS is configured, but not currently in use.
|
||||
if s.srv.TLSConfig != nil && !s.tls {
|
||||
response += "250-STARTTLS\r\n"
|
||||
}
|
||||
|
||||
// Only list AUTH if an AuthHandler is configured and at least one mechanism is allowed.
|
||||
if s.srv.AuthHandler != nil {
|
||||
var mechs []string
|
||||
for mech, allowed := range s.authMechs() {
|
||||
if allowed {
|
||||
mechs = append(mechs, mech)
|
||||
}
|
||||
}
|
||||
if len(mechs) > 0 {
|
||||
response += "250-AUTH " + strings.Join(mechs, " ") + "\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
response += "250 ENHANCEDSTATUSCODES"
|
||||
return
|
||||
}
|
||||
|
||||
func (s *session) handleAuthLogin(arg string) (bool, error) {
|
||||
var err error
|
||||
|
||||
if arg == "" {
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
arg, err = s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
username, err := base64.StdEncoding.DecodeString(arg)
|
||||
if err != nil {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
password, err := base64.StdEncoding.DecodeString(line)
|
||||
if err != nil {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "LOGIN", username, password, nil)
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
func (s *session) handleAuthPlain(arg string) (bool, error) {
|
||||
var err error
|
||||
|
||||
// If fast mode (AUTH PLAIN [arg]) is not used, prompt for credentials.
|
||||
if arg == "" {
|
||||
s.writef("334 ")
|
||||
arg, err = s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(arg)
|
||||
if err != nil {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
parts := bytes.Split(data, []byte{0})
|
||||
if len(parts) != 3 {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to parse)")
|
||||
}
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "PLAIN", parts[1], parts[2], nil)
|
||||
|
||||
return authenticated, err
|
||||
}
|
||||
|
||||
func (s *session) handleAuthCramMD5() (bool, error) {
|
||||
shared := "<" + strconv.Itoa(os.Getpid()) + "." + strconv.Itoa(time.Now().Nanosecond()) + "@" + s.srv.Hostname + ">"
|
||||
|
||||
s.writef("334 " + base64.StdEncoding.EncodeToString([]byte(shared)))
|
||||
|
||||
data, err := s.readLine()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if data == "*" {
|
||||
return false, errors.New("501 5.7.0 Authentication cancelled")
|
||||
}
|
||||
|
||||
buf, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to decode)")
|
||||
}
|
||||
|
||||
fields := strings.Split(string(buf), " ")
|
||||
if len(fields) < 2 {
|
||||
return false, errors.New("501 5.5.2 Syntax error (unable to parse)")
|
||||
}
|
||||
|
||||
// Validate credentials.
|
||||
authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "CRAM-MD5", []byte(fields[0]), []byte(fields[1]), []byte(shared))
|
||||
|
||||
return authenticated, err
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, build with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
|
||||
.glide/
|
|
@ -0,0 +1,13 @@
|
|||
os:
|
||||
- linux
|
||||
- osx
|
||||
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.7.x
|
||||
- 1.8.x
|
||||
- 1.9.x
|
||||
- 1.10.x
|
||||
|
||||
script: make build
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017 Amal Francis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,12 @@
|
|||
.PHONY: all
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
test:
|
||||
go test ./... -v
|
||||
|
||||
build: fmt vet test
|
|
@ -0,0 +1,98 @@
|
|||
maildir
|
||||
=======
|
||||
[![GitHub release](https://img.shields.io/github/release/amalfra/maildir.svg)](https://github.com/amalfra/maildir/releases)
|
||||
[![Build Status](https://travis-ci.org/amalfra/maildir.svg?branch=master)](https://travis-ci.org/amalfra/maildir)
|
||||
[![GoDoc](https://godoc.org/github.com/amalfra/maildir?status.svg)](https://godoc.org/github.com/amalfra/maildir)
|
||||
[![Go Report Card](https://goreportcard.com/badge/github.com/amalfra/maildir)](https://goreportcard.com/report/github.com/amalfra/maildir)
|
||||
|
||||
A go package for reading and writing messages in the maildir format.
|
||||
|
||||
> The Maildir e-mail format is a common way of storing e-mail messages, where each message is kept in a separate file with a unique name, and each folder is a directory. The local filesystem handles file locking as messages are added, moved and deleted. A major design goal of Maildir is to eliminate program code having to handle locking, which is often difficult.
|
||||
|
||||
Refer http://cr.yp.to/proto/maildir.html and http://en.wikipedia.org/wiki/Maildir
|
||||
|
||||
## Installation
|
||||
|
||||
You can download the package using
|
||||
|
||||
``` go
|
||||
go get github.com/amalfra/maildir
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Next, import the package
|
||||
|
||||
``` go
|
||||
import (
|
||||
"github.com/amalfra/maildir"
|
||||
)
|
||||
```
|
||||
|
||||
#### Create a maildir in /home/amal/mail
|
||||
``` go
|
||||
myMaildir := maildir.NewMaildir("/home/amal/mail")
|
||||
```
|
||||
|
||||
This command automatically creates the standard Maildir directories - `cur`,
|
||||
`new`, and `tmp` - if they do not exist.
|
||||
|
||||
#### Add a new message
|
||||
This creates a new file with the contents "foo"; returns the Message struct reference. Messages are written to the tmp dir then moved to new.
|
||||
``` go
|
||||
message, err := myMaildir.Add("foo")
|
||||
```
|
||||
|
||||
#### List new messages
|
||||
``` go
|
||||
mailList, err := myMaildir.List("new")
|
||||
```
|
||||
This will return a map of messages by key, sorted by key
|
||||
|
||||
#### List current messages
|
||||
``` go
|
||||
mailList, err := myMaildir.List("cur")
|
||||
```
|
||||
This will return a map of messages by key, sorted by key
|
||||
|
||||
#### Find the message using key
|
||||
``` go
|
||||
message := maildir.Get(key)
|
||||
```
|
||||
|
||||
#### Delete the message from disk by key
|
||||
``` go
|
||||
err := maildir.Delete(key)
|
||||
```
|
||||
|
||||
**Below are the methods that are available on Message instance**
|
||||
|
||||
#### Get the key used to uniquely identify the message
|
||||
``` go
|
||||
key := message.Key()
|
||||
```
|
||||
|
||||
#### Load the message content from file
|
||||
``` go
|
||||
data, err := message.GetData()
|
||||
```
|
||||
|
||||
#### Process message - move the message from "new" to "cur"
|
||||
This is usaully done to indicate that some process has retrieved the message.
|
||||
``` go
|
||||
key, err := message.Process("new")
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Questions, problems or suggestions? Please post them on the [issue tracker](https://github.com/amalfra/maildir/issues).
|
||||
|
||||
You can contribute changes by forking the project and submitting a pull request. You can ensure the tests are passing by running ```make test```. Feel free to contribute :heart_eyes:
|
||||
|
||||
## UNDER MIT LICENSE
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,10 @@
|
|||
package lib
|
||||
|
||||
// the seperator between unique name and info
|
||||
const colon = ':'
|
||||
|
||||
// default info, to which flags are appended
|
||||
const info = "2,"
|
||||
|
||||
// Subdirs has subdirectories that are required in maildir
|
||||
var Subdirs = []string{"tmp", "new", "cur"}
|
|
@ -0,0 +1,144 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Message represents a maildir message and has it's supported operations
|
||||
type Message struct {
|
||||
dir string
|
||||
maildir string
|
||||
info string
|
||||
unqiueName string
|
||||
oldKey string
|
||||
}
|
||||
|
||||
// NewMessage will create new message in specified mail directory
|
||||
func NewMessage(maildir string) (*Message, error) {
|
||||
var err error
|
||||
msg := new(Message)
|
||||
msg.maildir = maildir
|
||||
msg.dir = "tmp"
|
||||
msg.unqiueName, err = generate()
|
||||
if err != nil {
|
||||
return nil, errors.New("Failed to generate unqiue name")
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// parseKey will set dir, unqiueName, info based on the key
|
||||
func (m *Message) parseKey(key string) {
|
||||
// remove leading /
|
||||
key = strings.TrimPrefix(key, string(os.PathSeparator))
|
||||
parts := strings.Split(key, string(os.PathSeparator))
|
||||
m.dir = parts[0]
|
||||
filename := parts[1]
|
||||
parts = strings.Split(filename, string(colon))
|
||||
m.unqiueName = parts[0]
|
||||
if len(parts) > 1 {
|
||||
m.info = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// LoadMessage will populate message object by loading info from passed key
|
||||
func LoadMessage(maildir string, key string) *Message {
|
||||
msg := new(Message)
|
||||
msg.maildir = maildir
|
||||
msg.parseKey(key)
|
||||
return msg
|
||||
}
|
||||
|
||||
// filename returns the filename of the message
|
||||
func (m *Message) filename() string {
|
||||
return fmt.Sprintf("%s%c%s", m.unqiueName, colon, m.info)
|
||||
}
|
||||
|
||||
// Key returns the key to identify the message
|
||||
func (m *Message) Key() string {
|
||||
return filepath.Join(m.dir, m.filename())
|
||||
}
|
||||
|
||||
// path returns the full path to the message
|
||||
func (m *Message) path() string {
|
||||
return filepath.Join(m.maildir, m.Key())
|
||||
}
|
||||
|
||||
// oldPath returns the old full path to the message
|
||||
func (m *Message) oldPath() string {
|
||||
return filepath.Join(m.maildir, m.oldKey)
|
||||
}
|
||||
|
||||
// rename the message. Returns the new key if successful
|
||||
func (m *Message) rename(newDir string, newInfo string) (string, error) {
|
||||
// Save the old key so we can revert to the old state
|
||||
m.oldKey = m.Key()
|
||||
|
||||
// Set the new state
|
||||
m.dir = newDir
|
||||
if newInfo != "" {
|
||||
m.info = newInfo
|
||||
}
|
||||
|
||||
if m.oldPath() != m.path() {
|
||||
err := os.Rename(m.oldPath(), m.path())
|
||||
if err != nil {
|
||||
// restore old state
|
||||
if m.oldKey != "" {
|
||||
m.parseKey(m.oldKey)
|
||||
}
|
||||
return "", errors.New("Failed to rename folder")
|
||||
}
|
||||
}
|
||||
m.oldKey = ""
|
||||
return m.Key(), nil
|
||||
}
|
||||
|
||||
// Write will write data to disk. only work with messages which haven't been written to disk.
|
||||
// After successfully writing to disk, rename the message to new dir
|
||||
func (m *Message) Write(data string) error {
|
||||
if m.dir != "tmp" {
|
||||
return errors.New("Can only write messages in tmp")
|
||||
}
|
||||
|
||||
err := ioutil.WriteFile(m.path(), []byte(data), os.ModePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to write message to path %s", m.path())
|
||||
}
|
||||
|
||||
_, err = m.rename("new", "")
|
||||
if err != nil {
|
||||
return errors.New("Failed to rename folder")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process will move a message from new to cur, add info. Returns the message's key
|
||||
func (m *Message) Process() (string, error) {
|
||||
return m.rename("cur", info)
|
||||
}
|
||||
|
||||
// SetInfo will set info on a message
|
||||
func (m *Message) SetInfo(infoStr string) (string, error) {
|
||||
if m.dir != "cur" {
|
||||
return "", errors.New("Can only set info on cur messages")
|
||||
}
|
||||
return m.rename("cur", infoStr)
|
||||
}
|
||||
|
||||
// GetData returns the message's data from disk
|
||||
func (m *Message) GetData() (string, error) {
|
||||
dat, err := ioutil.ReadFile(m.path())
|
||||
strDat := string(dat)
|
||||
return strDat, err
|
||||
}
|
||||
|
||||
// Destroy will remove the message file
|
||||
func (m *Message) Destroy() error {
|
||||
return os.Remove(m.path())
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type uniqueName struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// generate will create and return unique file names for new messages
|
||||
func generate() (string, error) {
|
||||
uni := &uniqueName{now: time.Now()}
|
||||
hostname, err := uni.right()
|
||||
if err != nil {
|
||||
return "", errors.New("Failed to fetch hostname")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d.%s.%s", uni.left(), uni.middle(), hostname), nil
|
||||
}
|
||||
|
||||
// left part of the unique name is the number of seconds since the UNIX epoch
|
||||
func (uni *uniqueName) left() int64 {
|
||||
return uni.now.Unix()
|
||||
}
|
||||
|
||||
func (uni *uniqueName) microseconds() int64 {
|
||||
return uni.now.Unix() * 1000000
|
||||
}
|
||||
|
||||
// middle part of the unique name contains microsecond, process id, and a per-process incrementing counter
|
||||
func (uni *uniqueName) middle() string {
|
||||
return fmt.Sprintf("M%06dP%dQ%d", uni.microseconds(), os.Getpid(), getCounter())
|
||||
}
|
||||
|
||||
// right part is the hostname
|
||||
func (uni *uniqueName) right() (string, error) {
|
||||
name, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
var counter int
|
||||
var mu sync.Mutex
|
||||
|
||||
// getCounter will return the value of a global per-process incrementing counter
|
||||
func getCounter() int {
|
||||
return count()
|
||||
}
|
||||
|
||||
// resetCounter will reset incrementing counter's value
|
||||
func resetCounter() {
|
||||
counter = 0
|
||||
}
|
||||
|
||||
// count will increment the atomic counter and return its value
|
||||
func count() int {
|
||||
mu.Lock()
|
||||
counter++
|
||||
mu.Unlock()
|
||||
return counter
|
||||
}
|
||||
|
||||
// StringInSlice will return whether string exists in given slice or not
|
||||
func StringInSlice(a string, list []string) bool {
|
||||
for _, b := range list {
|
||||
if b == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package maildir
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/amalfra/maildir/lib"
|
||||
)
|
||||
|
||||
// Maildir implements maildir format and it's operations
|
||||
type Maildir struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// NewMaildir will create new maildir at specified path
|
||||
func NewMaildir(path string) *Maildir {
|
||||
maildir := new(Maildir)
|
||||
maildir.path = path
|
||||
|
||||
_, dCur := os.Stat(path + "/cur")
|
||||
_, dTmp := os.Stat(path + "/tmp")
|
||||
_, dNew := os.Stat(path + "/new")
|
||||
if os.IsNotExist(dCur) || os.IsNotExist(dTmp) || os.IsNotExist(dNew) {
|
||||
maildir.createDirectories()
|
||||
}
|
||||
|
||||
return maildir
|
||||
}
|
||||
|
||||
// createDirectories will the sub directories required by maildir
|
||||
func (m *Maildir) createDirectories() {
|
||||
for _, subDir := range lib.Subdirs {
|
||||
os.MkdirAll(filepath.Join(m.path, subDir), os.ModePerm)
|
||||
}
|
||||
}
|
||||
|
||||
// Add writes data out as a new message. Returns Message instance
|
||||
func (m *Maildir) Add(data string) (*lib.Message, error) {
|
||||
msg, err := lib.NewMessage(m.path)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to create message")
|
||||
}
|
||||
err = msg.Write(data)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to write message")
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// Get returns a message object for key
|
||||
func (m *Maildir) Get(key string) *lib.Message {
|
||||
return lib.LoadMessage(m.path, key)
|
||||
}
|
||||
|
||||
// List returns an array of messages from new or cur directory, sorted by key
|
||||
func (m *Maildir) List(dir string) (map[string]*lib.Message, error) {
|
||||
if !lib.StringInSlice(dir, lib.Subdirs) {
|
||||
return nil, errors.New("dir must be :new, :cur, or :tmp")
|
||||
}
|
||||
|
||||
keys, err := m.getDirListing(dir)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to get directory listing")
|
||||
}
|
||||
sort.Sort(sort.StringSlice(keys))
|
||||
|
||||
// map keys to message objects
|
||||
keyMap := make(map[string]*lib.Message)
|
||||
for _, key := range keys {
|
||||
keyMap[key] = m.Get(key)
|
||||
}
|
||||
|
||||
return keyMap, nil
|
||||
}
|
||||
|
||||
// getDirListing returns an array of keys in dir
|
||||
func (m *Maildir) getDirListing(dir string) ([]string, error) {
|
||||
filter := "*"
|
||||
searchPath := filepath.Join(m.path, dir, filter)
|
||||
filePaths, err := filepath.Glob(searchPath)
|
||||
// remove maildir path so that only key remains
|
||||
for i, filePath := range filePaths {
|
||||
filePaths[i] = strings.TrimPrefix(filePath, m.path)
|
||||
}
|
||||
return filePaths, err
|
||||
}
|
||||
|
||||
// Delete a message by key
|
||||
func (m *Maildir) Delete(key string) error {
|
||||
return m.Get(key).Destroy()
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# github.com/amalfra/maildir v0.0.7
|
||||
github.com/amalfra/maildir
|
||||
github.com/amalfra/maildir/lib
|
|
@ -0,0 +1,60 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"zangtumb/smtpd"
|
||||
)
|
||||
|
||||
var ZangSmtpServer *smtpd.Server
|
||||
var KeyFile, CrtFile, ServerName, ListenAddr string
|
||||
|
||||
const AppName = "ZangTumb"
|
||||
|
||||
func init() {
|
||||
|
||||
ZangSmtpServer = new(smtpd.Server)
|
||||
|
||||
KeyFile = os.Getenv("KEYFILE")
|
||||
log.Println("KeyFile: ", KeyFile)
|
||||
CrtFile = os.Getenv("CERTFILE")
|
||||
log.Println("CrtFile: ", CrtFile)
|
||||
ServerName = os.Getenv("DOMAINNAME")
|
||||
log.Println("ServerName: ", ServerName)
|
||||
ListenAddr = os.Getenv("LISTEN")
|
||||
log.Println("ListenAddr: ", ListenAddr)
|
||||
|
||||
ZangSmtpServer.AuthRequired = false
|
||||
ZangSmtpServer.Hostname = ServerName
|
||||
ZangSmtpServer.MaxRcpt = SmtpBackend.MaxRecipients
|
||||
if ZangSmtpServer.MaxRcpt == 0 {
|
||||
ZangSmtpServer.MaxRcpt = 1
|
||||
log.Println("No recipients, setting MaxRcpt to 1")
|
||||
} else {
|
||||
log.Println("MaxRcpt: ", ZangSmtpServer.MaxRcpt)
|
||||
}
|
||||
|
||||
if os.Getenv("USETLS") == "true" {
|
||||
|
||||
ZangSmtpServer.ConfigureTLS(CrtFile, KeyFile)
|
||||
ZangSmtpServer.TLSListener = false
|
||||
ZangSmtpServer.TLSRequired = true
|
||||
log.Println("Using TLS")
|
||||
|
||||
} else {
|
||||
|
||||
ZangSmtpServer.TLSListener = false
|
||||
ZangSmtpServer.TLSRequired = false
|
||||
log.Println("WARNING: NOT Using TLS")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
if err := smtpd.ListenAndServe(ListenAddr, mailHandler, AppName, ServerName, ZangSmtpServer); err != nil {
|
||||
log.Panicln("Ops. Something went wrong: ", err.Error())
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue