Initial commit

This commit is contained in:
2020-04-17 17:53:01 +02:00
commit 423843c2d7
49 changed files with 9669 additions and 0 deletions

View File

@ -0,0 +1,41 @@
package command
import (
"context"
"github.com/pkg/errors"
"forge.cadoles.com/wpetit/fake-smtp/internal/model"
"forge.cadoles.com/wpetit/fake-smtp/internal/storm"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type ClearInboxRequest struct{}
func HandleClearInbox(ctx context.Context, cmd cqrs.Command) error {
_, ok := cmd.Request().(*ClearInboxRequest)
if !ok {
return cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return errors.Wrap(err, "could not retrieve storm service")
}
if err := db.Select().Delete(&model.Email{}); err != nil {
if err == storm.ErrNotFound {
return nil
}
return errors.Wrap(err, "could not delete emails")
}
return nil
}

View File

@ -0,0 +1,43 @@
package command
import (
"context"
"github.com/pkg/errors"
"forge.cadoles.com/wpetit/fake-smtp/internal/model"
"forge.cadoles.com/wpetit/fake-smtp/internal/storm"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type DeleteEmailRequest struct {
EmailID int
}
func HandleDeleteEmail(ctx context.Context, cmd cqrs.Command) error {
req, ok := cmd.Request().(*DeleteEmailRequest)
if !ok {
return cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return errors.Wrap(err, "could not retrieve storm service")
}
if err := db.DeleteStruct(&model.Email{ID: req.EmailID}); err != nil {
if err == storm.ErrNotFound {
return nil
}
return errors.Wrap(err, "could not delete email")
}
return nil
}

View File

@ -0,0 +1,100 @@
package command
import (
"context"
"time"
"github.com/pkg/errors"
"forge.cadoles.com/wpetit/fake-smtp/internal/model"
"forge.cadoles.com/wpetit/fake-smtp/internal/storm"
"github.com/jhillyerd/enmime"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type StoreEmailRequest struct {
Envelope *enmime.Envelope
}
func HandleStoreEmail(ctx context.Context, cmd cqrs.Command) error {
req, ok := cmd.Request().(*StoreEmailRequest)
if !ok {
return cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return errors.Wrap(err, "could not retrieve storm service")
}
email := &model.Email{
Headers: make(map[string][]string),
SentAt: time.Now(),
}
email.HTML = req.Envelope.HTML
email.Text = req.Envelope.Text
for _, k := range req.Envelope.GetHeaderKeys() {
switch k {
case "From":
from, err := req.Envelope.AddressList(k)
if err != nil {
return errors.Wrapf(err, "could not retrieve '%s' adresses", k)
}
email.From = from
case "To":
to, err := req.Envelope.AddressList(k)
if err != nil {
return errors.Wrapf(err, "could not retrieve '%s' adresses", k)
}
email.To = to
case "Cc":
cc, err := req.Envelope.AddressList(k)
if err != nil {
return errors.Wrapf(err, "could not retrieve '%s' adresses", k)
}
email.Cc = cc
case "Cci":
cci, err := req.Envelope.AddressList(k)
if err != nil {
return errors.Wrapf(err, "could not retrieve '%s' adresses", k)
}
email.Cci = cci
case "Subject":
email.Subject = req.Envelope.GetHeader(k)
default:
email.Headers[k] = req.Envelope.GetHeaderValues(k)
}
}
email.Attachments = make([]*model.Attachment, 0, len(req.Envelope.Attachments))
for _, a := range req.Envelope.Attachments {
email.Attachments = append(email.Attachments, &model.Attachment{
ContentType: a.ContentType,
Name: a.FileName,
Data: a.Content,
})
}
if err := db.Save(email); err != nil {
return errors.Wrap(err, "could not save email")
}
return nil
}

108
internal/config/config.go Normal file
View File

@ -0,0 +1,108 @@
package config
import (
"io"
"io/ioutil"
"time"
"github.com/caarlos0/env/v6"
"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)
type Config struct {
HTTP HTTPConfig `yaml:"http"`
SMTP SMTPConfig `yaml:"smtp"`
Data DataConfig `ymal:"data"`
}
type HTTPConfig struct {
Address string `yaml:"address" env:"FAKESMTP_HTTP_ADDRESS"`
TemplateDir string `yaml:"templateDir" env:"FAKESMTP_HTTP_TEMPLATEDIR"`
PublicDir string `yaml:"publicDir" env:"FAKESMTP_HTTP_PUBLICDIR"`
}
type SMTPConfig struct {
Address string `yaml:"address" env:"FAKESMTP_SMTP_ADDRESS"`
Username string `yaml:"username" env:"FAKESMTP_SMTP_USERNAME"`
Password string `yaml:"password" env:"FAKESMTP_SMTP_PASSWORD"`
Domain string `yaml:"domain" env:"FAKESMTP_SMTP_DOMAIN"`
ReadTimeout time.Duration `yaml:"readTimeout" env:"FAKESMTP_SMTP_READTIMEOUT"`
WriteTimeout time.Duration `yaml:"writeTimeout" env:"FAKESMTP_SMTP_WRITETIMEOUT"`
MaxMessageBytes int `yaml:"maxMessageBytes" env:"FAKESMTP_SMTP_MAXMESSAGEBYTES"`
MaxRecipients int `yaml:"maxRecipients" env:"FAKESMTP_SMTP_MAXRECIPIENTS"`
AllowInsecureAuth bool `yaml:"allowInsecureAuth" env:"FAKESMTP_SMTP_ALLOWINSECUREAUTH"`
Debug bool `yaml:"debug" env:"FAKESMTP_SMTP_DEBUG"`
}
type DataConfig struct {
Path string `yaml:"path" env:"FAKESMTP_DATA_PATH"`
}
// NewFromFile retrieves the configuration from the given file
func NewFromFile(filepath string) (*Config, error) {
config := NewDefault()
data, err := ioutil.ReadFile(filepath)
if err != nil {
return nil, errors.Wrapf(err, "could not read file '%s'", filepath)
}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, errors.Wrapf(err, "could not unmarshal configuration")
}
return config, nil
}
func WithEnvironment(conf *Config) error {
if err := env.Parse(conf); err != nil {
return err
}
return nil
}
func NewDumpDefault() *Config {
config := NewDefault()
return config
}
func NewDefault() *Config {
return &Config{
HTTP: HTTPConfig{
Address: ":8080",
TemplateDir: "template",
PublicDir: "public",
},
SMTP: SMTPConfig{
Address: ":2525",
Username: "",
Password: "",
Domain: "localhost",
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
MaxMessageBytes: 1024 * 1024,
MaxRecipients: 50,
AllowInsecureAuth: true,
Debug: true,
},
Data: DataConfig{
Path: "fakesmtp.db",
},
}
}
func Dump(config *Config, w io.Writer) error {
data, err := yaml.Marshal(config)
if err != nil {
return errors.Wrap(err, "could not dump config")
}
if _, err := w.Write(data); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,9 @@
package config
import "gitlab.com/wpetit/goweb/service"
func ServiceProvider(config *Config) service.Provider {
return func(ctn *service.Container) (interface{}, error) {
return config, nil
}
}

View File

@ -0,0 +1,33 @@
package config
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "config"
// From retrieves the config service in the given container
func From(container *service.Container) (*Config, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*Config)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the config service in the given container or panic otherwise
func Must(container *service.Container) *Config {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}

27
internal/model/email.go Normal file
View File

@ -0,0 +1,27 @@
package model
import (
"net/mail"
"time"
)
type Email struct {
ID int `storm:"id,increment"`
HTML string
Text string
Headers map[string][]string
From []*mail.Address
To []*mail.Address
Cc []*mail.Address
Cci []*mail.Address
Subject string
SentAt time.Time
Attachments []*Attachment
Seen bool `storm:"index"`
}
type Attachment struct {
Name string
ContentType string
Data []byte
}

View File

@ -0,0 +1,63 @@
package query
import (
"context"
"forge.cadoles.com/wpetit/fake-smtp/internal/model"
"forge.cadoles.com/wpetit/fake-smtp/internal/storm"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type GetInboxRequest struct {
OrderBy string
Limit int
Skip int
Reverse bool
}
type InboxData struct {
Emails []*model.Email
}
func HandleGetInbox(ctx context.Context, qry cqrs.Query) (interface{}, error) {
req, ok := qry.Request().(*GetInboxRequest)
if !ok {
return nil, cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve storm service")
}
emails := make([]*model.Email, 0)
query := db.Select()
if req.OrderBy != "" {
query = query.OrderBy(req.OrderBy)
} else {
query = query.OrderBy("SentAt").Reverse()
}
if req.Reverse {
query = query.Reverse()
}
if err := query.Find(&emails); err != nil {
if err == storm.ErrNotFound {
return &InboxData{emails}, nil
}
return nil, errors.Wrap(err, "could not retrieve emails")
}
return &InboxData{emails}, nil
}

View File

@ -0,0 +1,44 @@
package query
import (
"context"
"forge.cadoles.com/wpetit/fake-smtp/internal/model"
"forge.cadoles.com/wpetit/fake-smtp/internal/storm"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type OpenEmailRequest struct {
EmailID int
}
type OpenEmailData struct {
Email *model.Email
}
func HandleOpenEmail(ctx context.Context, qry cqrs.Query) (interface{}, error) {
req, ok := qry.Request().(*OpenEmailRequest)
if !ok {
return nil, cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve storm service")
}
email := &model.Email{}
if err := db.One("ID", req.EmailID, email); err != nil {
return nil, errors.Wrap(err, "could not find email")
}
return &OpenEmailData{email}, nil
}

200
internal/route/email.go Normal file
View File

@ -0,0 +1,200 @@
package route
import (
"context"
"net/http"
"strconv"
"github.com/microcosm-cc/bluemonday"
"forge.cadoles.com/wpetit/fake-smtp/internal/command"
"forge.cadoles.com/wpetit/fake-smtp/internal/model"
"forge.cadoles.com/wpetit/fake-smtp/internal/query"
"forge.cadoles.com/wpetit/fake-smtp/internal/storm"
"github.com/go-chi/chi"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
)
func serveEmailPage(w http.ResponseWriter, r *http.Request) {
emailID, err := getEmailID(r)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ctx := r.Context()
email, err := openEmail(ctx, emailID)
if err != nil {
if errors.Is(err, storm.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
panic(errors.Wrap(err, "could not open email"))
}
ctn := container.Must(ctx)
tmpl := template.Must(ctn)
data := extendTemplateData(w, r, template.Data{
"Email": email,
})
if err := tmpl.RenderPage(w, "email.html.tmpl", data); err != nil {
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
}
}
func serveEmailHTMLContent(w http.ResponseWriter, r *http.Request) {
emailID, err := getEmailID(r)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ctx := r.Context()
email, err := openEmail(ctx, emailID)
if err != nil {
if errors.Is(err, storm.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
panic(errors.Wrap(err, "could not open email"))
}
html := email.HTML
if html == "" {
http.Error(w, http.StatusText(http.StatusNoContent), http.StatusNoContent)
return
}
policy := bluemonday.UGCPolicy()
sanitizedHTML := policy.Sanitize(email.HTML)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(sanitizedHTML))
}
func serveEmailAttachment(w http.ResponseWriter, r *http.Request) {
emailID, err := getEmailID(r)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ctx := r.Context()
email, err := openEmail(ctx, emailID)
if err != nil {
if errors.Is(err, storm.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
panic(errors.Wrap(err, "could not open email"))
}
attachmentIndex, err := getAttachmentIndex(r)
if err != nil {
if errors.Is(err, storm.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
panic(errors.Wrap(err, "could not open attachment"))
}
if attachmentIndex < 0 || attachmentIndex >= len(email.Attachments) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
attachment := email.Attachments[attachmentIndex]
w.Header().Set("Content-Type", attachment.ContentType)
if _, err := w.Write(attachment.Data); err != nil {
panic(errors.Wrap(err, "could not send attachment"))
}
}
func handleEmailDelete(w http.ResponseWriter, r *http.Request) {
emailID, err := getEmailID(r)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ctx := r.Context()
ctn := container.Must(ctx)
bus := cqrs.Must(ctn)
deleteEmail := &command.DeleteEmailRequest{
EmailID: emailID,
}
if _, err := bus.Exec(ctx, deleteEmail); err != nil {
panic(errors.Wrap(err, "could not delete email"))
}
http.Error(w, http.StatusText(http.StatusNoContent), http.StatusNoContent)
}
func getEmailID(r *http.Request) (int, error) {
rawEmailID := chi.URLParam(r, "id")
emailID, err := strconv.ParseInt(rawEmailID, 10, 32)
if err != nil {
return 0, err
}
return int(emailID), nil
}
func openEmail(ctx context.Context, emailID int) (*model.Email, error) {
ctn := container.Must(ctx)
bus := cqrs.Must(ctn)
req := &query.OpenEmailRequest{
EmailID: int(emailID),
}
result, err := bus.Query(ctx, req)
if err != nil {
return nil, err
}
openEmailData, ok := result.Data().(*query.OpenEmailData)
if !ok {
return nil, errors.New("unexpected result data")
}
return openEmailData.Email, nil
}
func getAttachmentIndex(r *http.Request) (int, error) {
rawAttachmendIndex := chi.URLParam(r, "attachmendIndex")
attachmendIndex, err := strconv.ParseInt(rawAttachmendIndex, 10, 32)
if err != nil {
return 0, err
}
return int(attachmendIndex), nil
}

22
internal/route/helper.go Normal file
View File

@ -0,0 +1,22 @@
package route
import (
"net/http"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
)
func extendTemplateData(w http.ResponseWriter, r *http.Request, data template.Data) template.Data {
ctn := container.Must(r.Context())
data, err := template.Extend(data,
template.WithBuildInfo(w, r, ctn),
)
if err != nil {
panic(errors.Wrap(err, "could not extend template data"))
}
return data
}

55
internal/route/inbox.go Normal file
View File

@ -0,0 +1,55 @@
package route
import (
"net/http"
"forge.cadoles.com/wpetit/fake-smtp/internal/command"
"forge.cadoles.com/wpetit/fake-smtp/internal/query"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
)
func serveInboxPage(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
tmpl := template.Must(ctn)
bus := cqrs.Must(ctn)
getInbox := &query.GetInboxRequest{}
ctx := r.Context()
result, err := bus.Query(ctx, getInbox)
if err != nil {
panic(errors.Wrap(err, "could not retrieve inbox"))
}
inboxData, ok := result.Data().(*query.InboxData)
if !ok {
panic(errors.New("unexpected data"))
}
data := extendTemplateData(w, r, template.Data{
"Emails": inboxData.Emails,
})
if err := tmpl.RenderPage(w, "inbox.html.tmpl", data); err != nil {
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
}
}
func handleClearInbox(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
bus := cqrs.Must(ctn)
clearInbox := &command.ClearInboxRequest{}
ctx := r.Context()
if _, err := bus.Exec(ctx, clearInbox); err != nil {
panic(errors.Wrap(err, "could not clear inbox"))
}
http.Error(w, http.StatusText(http.StatusNoContent), http.StatusNoContent)
}

24
internal/route/mount.go Normal file
View File

@ -0,0 +1,24 @@
package route
import (
"forge.cadoles.com/wpetit/fake-smtp/internal/config"
"github.com/go-chi/chi"
"gitlab.com/wpetit/goweb/static"
)
func Mount(r *chi.Mux, config *config.Config) error {
r.Group(func(r chi.Router) {
r.Get("/", serveInboxPage)
r.Delete("/emails", handleClearInbox)
r.Get("/emails/{id}", serveEmailPage)
r.Get("/emails/{id}/html", serveEmailHTMLContent)
r.Get("/emails/{id}/attachments/{attachmendIndex}", serveEmailAttachment)
r.Delete("/emails/{id}", handleEmailDelete)
})
notFoundHandler := r.NotFoundHandler()
r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler))
return nil
}

9
internal/storm/error.go Normal file
View File

@ -0,0 +1,9 @@
package storm
import (
"github.com/asdine/storm/v3"
)
var (
ErrNotFound = storm.ErrNotFound
)

51
internal/storm/option.go Normal file
View File

@ -0,0 +1,51 @@
package storm
type Option struct {
Path string
Objects []interface{}
ReIndex bool
Init bool
}
type OptionFunc func(*Option)
func DefaultOption() *Option {
return MergeOption(
&Option{},
WithPath("data.db"),
WithInit(true),
WithReIndex(true),
)
}
func MergeOption(opt *Option, funcs ...OptionFunc) *Option {
for _, fn := range funcs {
fn(opt)
}
return opt
}
func WithPath(path string) OptionFunc {
return func(opt *Option) {
opt.Path = path
}
}
func WithReIndex(reindex bool) OptionFunc {
return func(opt *Option) {
opt.ReIndex = reindex
}
}
func WithInit(init bool) OptionFunc {
return func(opt *Option) {
opt.Init = init
}
}
func WithObjects(objects ...interface{}) OptionFunc {
return func(opt *Option) {
opt.Objects = objects
}
}

View File

@ -0,0 +1,48 @@
package storm
import (
"reflect"
"github.com/asdine/storm/v3"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
func ServiceProvider(funcs ...OptionFunc) service.Provider {
opt := MergeOption(
DefaultOption(),
funcs...,
)
db, err := storm.Open(opt.Path)
if err == nil && opt.Objects != nil {
err = migrate(db, opt.Objects, opt.Init, opt.ReIndex)
}
return func(ctn *service.Container) (interface{}, error) {
if err != nil {
return nil, err
}
return db, nil
}
}
func migrate(db *storm.DB, objects []interface{}, init, reindex bool) error {
for _, o := range objects {
if init {
if err := db.Init(o); err != nil {
return errors.Wrapf(err, "could not init object '%s'", reflect.TypeOf(o).String())
}
}
if reindex {
if err := db.ReIndex(o); err != nil {
return errors.Wrapf(err, "could not reindex object '%s'", reflect.TypeOf(o).String())
}
}
}
return nil
}

34
internal/storm/service.go Normal file
View File

@ -0,0 +1,34 @@
package storm
import (
"github.com/asdine/storm/v3"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "storm"
// From retrieves the storm service in the given container
func From(container *service.Container) (*storm.DB, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*storm.DB)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the storm service in the given container or panic otherwise
func Must(container *service.Container) *storm.DB {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}