From e84e0e3d4b1d7b094f491d9430c3a3ce89bd0948 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Sun, 13 Aug 2023 03:04:16 +0100 Subject: [PATCH] Start coding the mailbox api --- .github/workflows/test.yml | 15 ++++++ api/api.go | 58 +++++++++++++++++++++ cmd/primrose/conf.go | 13 +++++ cmd/primrose/main.go | 5 ++ imap/client.go | 56 ++++++++++++++++++++ imap/imap.go | 35 +++++++++++++ smtp/json.go | 101 +++++++++++++++++++++++++++++++++++++ smtp/smtp.go | 45 +++++++++++++++++ smtp/smtp_test.go | 21 ++++++++ 9 files changed, 349 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 api/api.go create mode 100644 cmd/primrose/conf.go create mode 100644 cmd/primrose/main.go create mode 100644 imap/client.go create mode 100644 imap/imap.go create mode 100644 smtp/json.go create mode 100644 smtp/smtp.go create mode 100644 smtp/smtp_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6abb452 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,15 @@ +on: [push, pull_request] +name: Test +jobs: + test: + strategy: + matrix: + go-version: [1.21.x] + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + - uses: actions/checkout@v3 + - run: go build ./cmd/primrose/ + - run: go test ./... diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..4b18891 --- /dev/null +++ b/api/api.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "github.com/1f349/primrose/imap" + "github.com/1f349/primrose/smtp" + "github.com/julienschmidt/httprouter" + "net/http" + "time" +) + +type Conf struct { + Listen string `yaml:"listen"` +} + +func SetupApiServer(conf Conf, send *smtp.Smtp, recv *imap.Imap) *http.Server { + r := httprouter.New() + + // smtp + r.POST("/message", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + // check body exists + if req.Body == nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + // parse json body + var j smtp.Json + err := json.NewDecoder(req.Body).Decode(&j) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + mail, err := j.PrepareMail() + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + if send.Send(mail) != nil { + rw.WriteHeader(http.StatusInternalServerError) + return + } + + rw.WriteHeader(http.StatusAccepted) + }) + + return &http.Server{ + Addr: conf.Listen, + Handler: r, + ReadTimeout: time.Minute, + ReadHeaderTimeout: time.Minute, + WriteTimeout: time.Minute, + IdleTimeout: time.Minute, + MaxHeaderBytes: 2500, + } +} diff --git a/cmd/primrose/conf.go b/cmd/primrose/conf.go new file mode 100644 index 0000000..b829463 --- /dev/null +++ b/cmd/primrose/conf.go @@ -0,0 +1,13 @@ +package main + +import ( + "github.com/1f349/primrose/api" + "github.com/1f349/primrose/imap" + "github.com/1f349/primrose/smtp" +) + +type Conf struct { + Smtp smtp.Smtp `yaml:"smtp"` + Imap imap.Imap `yaml:"imap"` + Api api.Conf `yaml:"api"` +} diff --git a/cmd/primrose/main.go b/cmd/primrose/main.go new file mode 100644 index 0000000..914319c --- /dev/null +++ b/cmd/primrose/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + // TODO(Melon): write start up code +} diff --git a/imap/client.go b/imap/client.go new file mode 100644 index 0000000..776933a --- /dev/null +++ b/imap/client.go @@ -0,0 +1,56 @@ +package imap + +import ( + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" +) + +var imapStatusFlags = []imap.StatusItem{ + imap.StatusMessages, + imap.StatusRecent, + imap.StatusUidNext, + imap.StatusUidValidity, + imap.StatusUnseen, +} + +type Client struct { + ic *client.Client +} + +func (c *Client) Status(folder string) (*imap.MailboxStatus, error) { + mbox, err := c.ic.Status(folder, imapStatusFlags) + return mbox, err +} + +func (c *Client) Fetch(folder string, start, end, limit uint32) ([]*imap.Message, error) { + // select the mailbox + mbox, err := c.ic.Select(folder, false) + if err != nil { + return nil, err + } + + // setup fetch range + if end > mbox.Messages { + end = mbox.Messages + } + if end-start > limit { + start = end - (limit - 1) + } + seqSet := new(imap.SeqSet) + seqSet.AddRange(start, end) + + messages := make(chan *imap.Message, limit) + done := make(chan error, 1) + go func() { + done <- c.ic.Fetch(seqSet, []imap.FetchItem{imap.FetchEnvelope}, messages) + }() + + outMsg := make([]*imap.Message, 0, limit) + for msg := range messages { + outMsg = append(outMsg, msg) + } + if err := <-done; err != nil { + return nil, err + } + return outMsg, nil +} diff --git a/imap/imap.go b/imap/imap.go new file mode 100644 index 0000000..2cb6549 --- /dev/null +++ b/imap/imap.go @@ -0,0 +1,35 @@ +package imap + +import ( + "fmt" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-sasl" +) + +type Imap struct { + Server string `yaml:"server"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Separator string `yaml:"separator"` +} + +func (i *Imap) MakeClient(user string) (*Client, error) { + // dial imap server + imapClient, err := client.Dial(i.Server) + if err != nil { + return nil, err + } + + // prepare login details + un := fmt.Sprintf("%s%s%s", user, i.Separator, i.Username) + saslLogin := sasl.NewPlainClient("", un, i.Password) + + // authenticate + err = imapClient.Authenticate(saslLogin) + if err != nil { + return nil, err + } + + // new client + return &Client{ic: imapClient}, nil +} diff --git a/smtp/json.go b/smtp/json.go new file mode 100644 index 0000000..eee11b7 --- /dev/null +++ b/smtp/json.go @@ -0,0 +1,101 @@ +package smtp + +import ( + "bytes" + "errors" + "github.com/emersion/go-message" + "github.com/emersion/go-message/mail" + "strings" + "time" +) + +var ( + ErrInvalidBodyType = errors.New("invalid body type") + ErrMultipleFromAddresses = errors.New("multiple from addresses") +) + +type Json struct { + From string `json:"from"` + ReplyTo string `json:"reply_to"` + To string `json:"to"` + Cc string `json:"cc"` + Bcc string `json:"bcc"` + Subject string `json:"subject"` + BodyType string `json:"body_type"` + Body string `json:"body"` +} + +func (s Json) parseAddresses() (addrFrom, addrReplyTo, addrTo, addrCc, addrBcc []*mail.Address, err error) { + // parse addresses + addrFrom, err = mail.ParseAddressList(s.From) + if err != nil { + return + } + addrReplyTo, err = mail.ParseAddressList(s.ReplyTo) + if err != nil { + return + } + addrTo, err = mail.ParseAddressList(s.To) + if err != nil { + return + } + addrCc, err = mail.ParseAddressList(s.Cc) + if err != nil { + return + } + addrBcc, err = mail.ParseAddressList(s.Bcc) + return +} + +func (s Json) PrepareMail() (*Mail, error) { + // parse addresses from json data + addrFrom, addrReplyTo, addrTo, addrCc, addrBcc, err := s.parseAddresses() + if err != nil { + return nil, err + } + + // only one from address allowed here + if len(addrFrom) != 1 { + return nil, ErrMultipleFromAddresses + } + + // save for use in the caller + from := addrFrom[0].Address + + // set base headers + var h mail.Header + h.SetDate(time.Now()) + h.SetSubject(s.Subject) + h.SetAddressList("From", addrFrom) + h.SetAddressList("Reply-To", addrReplyTo) + h.SetAddressList("To", addrTo) + h.SetAddressList("Cc", addrCc) + + // set content type header + switch s.BodyType { + case "plain": + h.Set("Content-Type", "text/plain; charset=utf-8") + case "html": + h.Set("Content-Type", "text/html; charset=utf-8") + default: + return nil, ErrInvalidBodyType + } + + entity, err := message.New(h.Header, strings.NewReader(s.Body)) + if err != nil { + return nil, err + } + + m := &Mail{ + from: from, + deliver: CreateSenderSlice(addrTo, addrCc, addrBcc), + } + + out := new(bytes.Buffer) + if err := entity.WriteTo(out); err != nil { + return nil, err + } + + m.body = out.Bytes() + return m, nil +} diff --git a/smtp/smtp.go b/smtp/smtp.go new file mode 100644 index 0000000..4201314 --- /dev/null +++ b/smtp/smtp.go @@ -0,0 +1,45 @@ +package smtp + +import ( + "bytes" + "github.com/emersion/go-message/mail" + "github.com/emersion/go-smtp" +) + +type Smtp struct { + Server string `yaml:"server"` +} + +type Mail struct { + from string + deliver []string + body []byte +} + +func (s *Smtp) Send(mail *Mail) error { + // dial smtp server + smtpClient, err := smtp.Dial(s.Server) + if err != nil { + return err + } + + // use a reader to send bytes + r := bytes.NewReader(mail.body) + + // send mail + return smtpClient.SendMail(mail.from, mail.deliver, r) +} + +func CreateSenderSlice(to, cc, bcc []*mail.Address) []string { + a := make([]string, 0, len(to)+len(cc)+len(bcc)) + for _, i := range to { + a = append(a, i.Address) + } + for _, i := range cc { + a = append(a, i.Address) + } + for _, i := range bcc { + a = append(a, i.Address) + } + return a +} diff --git a/smtp/smtp_test.go b/smtp/smtp_test.go new file mode 100644 index 0000000..96adbff --- /dev/null +++ b/smtp/smtp_test.go @@ -0,0 +1,21 @@ +package smtp + +import ( + "github.com/emersion/go-message/mail" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestCreateSenderSlice(t *testing.T) { + a := []*mail.Address{{Address: "a@example.com"}, {Address: "b@example.com"}} + b := []*mail.Address{{Address: "a@example.com"}, {Address: "c@example.com"}} + c := []*mail.Address{{Address: "a@example.com"}, {Address: "d@example.com"}} + assert.Equal(t, []string{ + "a@example.com", + "b@example.com", + "a@example.com", + "c@example.com", + "a@example.com", + "d@example.com", + }, CreateSenderSlice(a, b, c)) +}