├── README.md ├── encode.go ├── go.mod ├── go.sum ├── main.go ├── message.go ├── message_test.go └── pickup.go /README.md: -------------------------------------------------------------------------------- 1 | # PowerMTA Email API 2 | 3 | Submit emails to PowerMTA with HTTP and JSON instead of SMTP and MIME. 4 | 5 | The Email API is used to submit email messages for delivery. You specify address headers, subject, text, and html. The Email API assembles the email, encodes it according to the appropriate standards, and submits it to PowerMTA. 6 | 7 | ## Getting started 8 | 9 | Start on PowerMTA server with: 10 | 11 | pmtaemailapi -listen 127.0.0.1:8000 -pickup /var/pickup 12 | 13 | Submit email messages by posting JSON to http://127.0.0.1:8000/messages. 14 | 15 | curl http://127.0.0.1:8000/messages -d '{"from":"postmaster@sender.com","to":"nobody@example.com","subject":"Test","text":"This is a test"}' 16 | 17 | ## API reference 18 | 19 | The JSON message object can contain the following name/value pairs: 20 | 21 | |Name |Type |Description | 22 | |-------|-------------------|-------------------------------| 23 | |sender |string |Envelope sender address | 24 | |from |object/string |Address to use in from header | 25 | |to |array/object/string|Address(es) to use in to header| 26 | |cc |array/object/string|Address(es) to use in cc header| 27 | |bcc |array/object/string|Address(es) to use as bcc | 28 | |subject|string |Subject line | 29 | |text |string |Content for text part | 30 | |html |string |Content for html part | 31 | 32 | An address can be specified as object or as string. An address object contains the following name/value pairs: 33 | 34 | |Name |Type |Description | 35 | |-------|-------------------|-------------------------------| 36 | |name |string |Display name | 37 | |address|string |Email address | 38 | 39 | All strings are expected to be encoded as UTF-8. Display names are MIME word-encoded if needed. Text and HTML bodies are transfer-encoded with quoted-printable. 40 | 41 | There is no access control. Make sure that the API is listening on private IPs or use a firewall. 42 | 43 | ## Example code 44 | 45 | ### Python 46 | 47 | Install requests library with `easy_install requests`. 48 | 49 | import json 50 | import requests 51 | 52 | url = "http://localhost:8000/messages" 53 | headers = {'content-type': 'application/json'} 54 | payload = { 55 | 'from': 'Jim ', 56 | 'to': 'you@example.com', 57 | 'subject': 'test', 58 | 'text': 'This is a test'} 59 | r = requests.post(url, data=json.dumps(payload), headers=headers) 60 | print r.status_code 61 | print r.text 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /encode.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "mime" 6 | "mime/multipart" 7 | "mime/quotedprintable" 8 | "net/textproto" 9 | "time" 10 | ) 11 | 12 | func encode(w io.Writer, m *message) { 13 | 14 | if m.Sender != "" { 15 | writeHeaderField(w, "x-sender", m.Sender) 16 | } else { 17 | writeHeaderField(w, "x-sender", m.From.Address.Address) 18 | } 19 | if m.To != nil { 20 | for _, a := range m.To.recipients() { 21 | writeHeaderField(w, "x-receiver", a) 22 | } 23 | } 24 | if m.Cc != nil { 25 | for _, a := range m.Cc.recipients() { 26 | writeHeaderField(w, "x-receiver", a) 27 | } 28 | } 29 | if m.Bcc != nil { 30 | for _, a := range m.Bcc.recipients() { 31 | writeHeaderField(w, "x-receiver", a) 32 | } 33 | } 34 | 35 | writeHeaderField(w, "Date", time.Now().Format(time.RFC1123Z)) 36 | writeHeaderField(w, "From", m.From.String()) 37 | 38 | // optional header fields 39 | if m.To != nil { 40 | writeHeaderField(w, "To", m.To.String()) 41 | } 42 | if m.Cc != nil { 43 | writeHeaderField(w, "Cc", m.Cc.String()) 44 | } 45 | if m.ReplyTo != nil { 46 | writeHeaderField(w, "Reply-To", m.ReplyTo.String()) 47 | } 48 | if m.Subject != "" { 49 | writeHeaderField(w, "Subject", mime.QEncoding.Encode("utf-8", m.Subject)) 50 | } 51 | 52 | switch { 53 | case m.Text != "" && m.HTML != "": 54 | mpw := multipart.NewWriter(w) 55 | writeMultipartHeader(w, "alternative", mpw.Boundary()) 56 | 57 | th := make(textproto.MIMEHeader) 58 | th.Set("Content-Type", "text/plain; charset=utf-8") 59 | th.Set("Content-Transfer-Encoding", "quoted-printable") 60 | tp, _ := mpw.CreatePart(th) 61 | writeQuotedPrintable(tp, m.Text) 62 | 63 | hh := make(textproto.MIMEHeader) 64 | hh.Set("Content-Type", "text/html; charset=utf-8") 65 | hh.Set("Content-Transfer-Encoding", "quoted-printable") 66 | mp, _ := mpw.CreatePart(hh) 67 | writeQuotedPrintable(mp, m.HTML) 68 | 69 | mpw.Close() 70 | case m.Text != "": 71 | writeTextBodyHeader(w, "plain") 72 | io.WriteString(w, "\r\n") 73 | writeQuotedPrintable(w, m.Text) 74 | case m.HTML != "": 75 | writeTextBodyHeader(w, "html") 76 | io.WriteString(w, "\r\n") 77 | writeQuotedPrintable(w, m.HTML) 78 | default: 79 | io.WriteString(w, "\r\n") // empty body 80 | } 81 | } 82 | 83 | func writeHeaderField(w io.Writer, name, body string) { 84 | io.WriteString(w, name) 85 | io.WriteString(w, ": ") 86 | io.WriteString(w, body) // TODO: folding 87 | io.WriteString(w, "\r\n") 88 | } 89 | 90 | func writeQuotedPrintable(w io.Writer, text string) { 91 | qpw := quotedprintable.NewWriter(w) 92 | io.WriteString(qpw, text) 93 | qpw.Close() 94 | } 95 | 96 | func writeMultipartHeader(w io.Writer, subtype string, boundary string) { 97 | writeHeaderField(w, "MIME-Version", "1.0") 98 | writeHeaderField(w, "Content-Type", "multipart/"+subtype+ 99 | ";\r\n boundary=\""+boundary+"\"") 100 | } 101 | 102 | func writeTextBodyHeader(w io.Writer, subtype string) { 103 | writeHeaderField(w, "MIME-Version", "1.0") 104 | writeHeaderField(w, "Content-Type", "text/"+subtype+"; charset=utf-8") 105 | writeHeaderField(w, "Content-Transfer-Encoding", "quoted-printable") 106 | } 107 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module pmtaemailapi 2 | 3 | go 1.12 4 | 5 | require github.com/drhodes/golorem v0.0.0-20160418191928-ecccc744c2d9 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/drhodes/golorem v0.0.0-20160418191928-ecccc744c2d9 h1:EQOZw/LCQ0SM4sNez3EhUf9gQalQrLrs4mPtmQa+d58= 2 | github.com/drhodes/golorem v0.0.0-20160418191928-ecccc744c2d9/go.mod h1:NsKVpF4h4j13Vm6Cx7Kf0V03aJKjfaStvm5rvK4+FyQ= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "log" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | 13 | var ( 14 | addr = "127.0.0.1:8000" 15 | dir = "/var/pickup" 16 | ) 17 | 18 | flag.StringVar(&addr, "listen", addr, "host:port to listen for http requests") 19 | flag.StringVar(&dir, "pickup", dir, "directory used for pickup by powermta") 20 | flag.Parse() 21 | 22 | _, err := os.Stat(dir) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | log.Printf("Emails will be placed in %s for pickup", dir) 27 | 28 | h := &handler{ 29 | pickup: pickup(dir), 30 | } 31 | http.Handle("/messages", h) 32 | 33 | log.Printf("Post messages to http://%s/messages", addr) 34 | err = http.ListenAndServe(addr, nil) 35 | // One can use generate_cert.go in crypto/tls to generate cert.pem and key.pem. 36 | //err := http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil) 37 | log.Fatal(err) 38 | 39 | } 40 | 41 | type handler struct { 42 | pickup pickup 43 | } 44 | 45 | func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 46 | 47 | msg := new(message) 48 | dec := json.NewDecoder(req.Body) 49 | err := dec.Decode(msg) 50 | if err != nil { 51 | log.Printf("%v", err) 52 | http.Error(w, err.Error(), http.StatusBadRequest) 53 | return 54 | } 55 | 56 | if msg.From == nil { 57 | log.Printf("\"from\" missing") 58 | http.Error(w, "\"from\" missing", http.StatusBadRequest) 59 | return 60 | } 61 | 62 | err = h.pickup.submit(msg) 63 | if err != nil { 64 | log.Printf("%v", err) 65 | http.Error(w, err.Error(), http.StatusInternalServerError) 66 | return 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/mail" 7 | "strings" 8 | ) 9 | 10 | type message struct { 11 | Sender string 12 | 13 | From *address // mandatory 14 | ReplyTo *address 15 | 16 | To, Cc, Bcc *addressList 17 | 18 | Subject string 19 | 20 | HTML string 21 | Text string 22 | } 23 | 24 | type address struct { 25 | mail.Address 26 | } 27 | 28 | func (a *address) UnmarshalJSON(data []byte) error { 29 | var addr mail.Address // parent type to avoid recursion 30 | if err := json.Unmarshal(data, &addr); err == nil { 31 | a.Address = addr 32 | return nil 33 | } 34 | var email string 35 | if err := json.Unmarshal(data, &email); err == nil { 36 | a.Address = mail.Address{Address: email} 37 | return nil 38 | } 39 | return fmt.Errorf("cannot decode %q as address", string(data)) 40 | } 41 | 42 | func (a *address) String() string { 43 | return a.Address.String() 44 | } 45 | 46 | type addressList []*address 47 | 48 | func (l *addressList) UnmarshalJSON(data []byte) error { 49 | var list []*address // parent type to avoid recursion 50 | if err := json.Unmarshal(data, &list); err == nil { 51 | *l = addressList(list) 52 | return nil 53 | } 54 | var addr address 55 | if err := json.Unmarshal(data, &addr); err == nil { 56 | *l = addressList{&addr} 57 | return nil 58 | } 59 | return fmt.Errorf("cannot decode %q as address list", string(data)) 60 | } 61 | 62 | func (l addressList) String() string { 63 | s := make([]string, len(l)) 64 | for i, a := range l { 65 | s[i] = a.String() 66 | } 67 | return strings.Join(s, ", ") 68 | } 69 | 70 | func (l addressList) recipients() []string { 71 | s := make([]string, len(l)) 72 | for i, a := range l { 73 | s[i] = a.Address.Address 74 | } 75 | return s 76 | } 77 | -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | lorem "github.com/drhodes/golorem" 8 | ) 9 | 10 | func TestDecodeJSON(t *testing.T) { 11 | 12 | data := `{ 13 | "sender": "bounce@example.com", 14 | "from": {"name": "Jérôme", "address": "jerome@example.com"}, 15 | "to": "john@hotmail.com", 16 | "cc": {"name": "Simon", "address": "simon@hotmail.com"}, 17 | "bcc": [{"name": "Lee", "address": "Lee@hotmail.com"}, {"name": "Jim", "address": "jim@hotmail.com"}], 18 | "subject": "Test message", 19 | "text": "` + lorem.Paragraph(200, 200) + `" 20 | }` 21 | 22 | msg := new(message) 23 | err := json.Unmarshal([]byte(data), msg) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | //encode(os.Stdout, msg) 28 | } 29 | -------------------------------------------------------------------------------- /pickup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | type pickup string 11 | 12 | func (p pickup) submit(msg *message) (err error) { 13 | file, err := newTempFile() 14 | if err != nil { 15 | return 16 | } 17 | 18 | encode(file, msg) 19 | err = file.Close() 20 | if err != nil { 21 | return 22 | } 23 | 24 | // move to pickup directory 25 | // Instead of creating the file elsewhere and then moving to the pickup directory, 26 | // it is also possible to create the file directly in it. However, since PowerMTA 27 | // must repeatedly attempt to lock the file for exclusive access, it is less efficient 28 | // to do so. 29 | name := file.Name() 30 | dest := filepath.Join(string(p), filepath.Base(name)) 31 | err = os.Rename(name, dest) 32 | if err != nil { 33 | return 34 | } 35 | return 36 | } 37 | 38 | var tempDir = os.TempDir() 39 | 40 | func newTempFile() (file *os.File, err error) { 41 | for n := 0; n < 10; n++ { 42 | name := filepath.Join(tempDir, randName()) 43 | file, err = os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) 44 | if err == nil || !os.IsExist(err) { 45 | return 46 | } 47 | } 48 | return 49 | } 50 | 51 | func randName() string { 52 | b := make([]byte, 8) 53 | rand.Read(b) 54 | return fmt.Sprintf("%x.eml", b) 55 | } 56 | --------------------------------------------------------------------------------