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,67 @@
package main
import (
"gitlab.com/wpetit/goweb/template/html"
"forge.cadoles.com/wpetit/fake-smtp/internal/command"
"forge.cadoles.com/wpetit/fake-smtp/internal/config"
"forge.cadoles.com/wpetit/fake-smtp/internal/query"
"forge.cadoles.com/wpetit/fake-smtp/internal/storm"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/service"
"gitlab.com/wpetit/goweb/service/build"
"gitlab.com/wpetit/goweb/service/template"
)
func getServiceContainer(conf *config.Config) (*service.Container, error) {
// Initialize and configure service container
ctn := service.NewContainer()
ctn.Provide(build.ServiceName, build.ServiceProvider(ProjectVersion, GitRef, BuildDate))
// Create and expose template service provider
ctn.Provide(template.ServiceName, html.ServiceProvider(
conf.HTTP.TemplateDir,
))
// Create and expose config service provider
ctn.Provide(config.ServiceName, config.ServiceProvider(conf))
ctn.Provide(storm.ServiceName, storm.ServiceProvider(
storm.WithPath(conf.Data.Path),
))
ctn.Provide(cqrs.ServiceName, cqrs.ServiceProvider())
bus, err := cqrs.From(ctn)
if err != nil {
return nil, err
}
bus.RegisterCommand(
cqrs.MatchCommandRequest(&command.StoreEmailRequest{}),
cqrs.CommandHandlerFunc(command.HandleStoreEmail),
)
bus.RegisterCommand(
cqrs.MatchCommandRequest(&command.ClearInboxRequest{}),
cqrs.CommandHandlerFunc(command.HandleClearInbox),
)
bus.RegisterCommand(
cqrs.MatchCommandRequest(&command.DeleteEmailRequest{}),
cqrs.CommandHandlerFunc(command.HandleDeleteEmail),
)
bus.RegisterQuery(
cqrs.MatchQueryRequest(&query.GetInboxRequest{}),
cqrs.QueryHandlerFunc(query.HandleGetInbox),
)
bus.RegisterQuery(
cqrs.MatchQueryRequest(&query.OpenEmailRequest{}),
cqrs.QueryHandlerFunc(query.HandleOpenEmail),
)
return ctn, nil
}

119
cmd/fake-smtp/main.go Normal file
View File

@ -0,0 +1,119 @@
package main
import (
"net/http"
"forge.cadoles.com/wpetit/fake-smtp/internal/route"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"gitlab.com/wpetit/goweb/middleware/container"
"flag"
"fmt"
"log"
"os"
"forge.cadoles.com/wpetit/fake-smtp/internal/config"
"github.com/pkg/errors"
)
//nolint: gochecknoglobals
var (
configFile = ""
workdir = ""
dumpConfig = false
version = false
)
// nolint: gochecknoglobals
var (
GitRef = "unknown"
ProjectVersion = "unknown"
BuildDate = "unknown"
)
//nolint: gochecknoinits
func init() {
flag.StringVar(&configFile, "config", configFile, "configuration file")
flag.StringVar(&workdir, "workdir", workdir, "working directory")
flag.BoolVar(&dumpConfig, "dump-config", dumpConfig, "dump configuration and exit")
flag.BoolVar(&version, "version", version, "show version and exit")
}
func main() {
flag.Parse()
if version {
fmt.Printf("%s (%s) - %s\n", ProjectVersion, GitRef, BuildDate)
os.Exit(0)
}
// Switch to new working directory if defined
if workdir != "" {
if err := os.Chdir(workdir); err != nil {
log.Fatalf("%+v", errors.Wrapf(err, "could not change working directory to '%s'", workdir))
}
}
// Load configuration file if defined, use default configuration otherwise
var conf *config.Config
var err error
if configFile != "" {
conf, err = config.NewFromFile(configFile)
if err != nil {
log.Fatalf("%+v", errors.Wrapf(err, "could not load config file '%s'", configFile))
}
} else {
if dumpConfig {
conf = config.NewDumpDefault()
} else {
conf = config.NewDefault()
}
}
// Dump configuration if asked
if dumpConfig {
if err := config.Dump(conf, os.Stdout); err != nil {
log.Fatalf("%+v", errors.Wrap(err, "could not dump config"))
}
os.Exit(0)
}
if err := config.WithEnvironment(conf); err != nil {
log.Fatalf("%+v", errors.Wrap(err, "could not override config with environment"))
}
// Create service container
ctn, err := getServiceContainer(conf)
if err != nil {
log.Fatalf("%+v", errors.Wrap(err, "could not create service container"))
}
go startSMTPServer(conf, ctn)
r := chi.NewRouter()
// Define base middlewares
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Expose service container on router
r.Use(container.ServiceContainer(ctn))
// Define routes
if err := route.Mount(r, conf); err != nil {
log.Fatalf("%+v", errors.Wrap(err, "could not mount http routes"))
}
log.Printf("listening on '%s'", conf.HTTP.Address)
if err := http.ListenAndServe(conf.HTTP.Address, r); err != nil {
log.Fatalf("%+v", errors.Wrapf(err, "could not listen on '%s'", conf.HTTP.Address))
}
}

3
cmd/fake-smtp/public/dist/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*
!.gitignore
!.gitkeep

0
cmd/fake-smtp/public/dist/.gitkeep vendored Normal file
View File

View File

@ -0,0 +1,11 @@
import { Controller } from "stimulus"
export default class extends Controller {
onLoad(evt) {
const iframe = evt.currentTarget;
iframe.style.height = iframe.contentWindow.document.body.scrollHeight + 'px';
const links = iframe.contentWindow.document.querySelectorAll('a');
Array.from(links).forEach(link => link.target = '_blank');
}
}

View File

@ -0,0 +1,42 @@
import { Controller } from "stimulus"
export default class extends Controller {
connect() {
this.onClick = this.onClick.bind(this);
this.element.addEventListener('click', this.onClick);
}
disconnect() {
this.element.removeEventListener('click', this.onClick);
}
onClick(evt) {
evt.preventDefault();
const config = {
method: this.data.get('method') || 'GET',
};
if (this.data.has('payload')) {
config.body = this.data.get('payload');
}
const endpoint = this.data.get('endpoint');
fetch(endpoint, config)
.then(res => {
if (res.status < 200 && res.status >= 400) {
throw new Error(`Unexpected server response: ${res.status} - ${res.statusText}`);
}
return res;
})
.then(() => {
const redirect = this.data.get('redirect');
if (redirect) {
window.location = redirect;
} else {
window.location.reload();
}
})
}
}

View File

@ -0,0 +1,22 @@
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["tab", "tabContent"]
openTab(evt) {
const tab = evt.currentTarget;
const tabName = tab.dataset.tabsName;
this.tabTargets.forEach(el => el.classList.remove('is-active'));
evt.currentTarget.classList.add('is-active');
this.tabContentTargets.forEach(el => {
if (el.dataset.tabsFor === tabName) {
el.style.display = 'inherit';
} else {
el.style.display = 'none';
}
});
}
}

View File

@ -0,0 +1,9 @@
import { Application } from "stimulus";
import { definitionsFromContext } from "stimulus/webpack-helpers";
import "bulma/bulma.sass";
import "./scss/main.scss";
const application = Application.start();
const context = require.context("./controllers", true, /\.js$/);
application.load(definitionsFromContext(context));

View File

@ -0,0 +1,35 @@
.inbox {
table-layout: fixed;
td {
overflow: hidden;
text-overflow: ellipsis;
}
td > * {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.email-subject {
width: 50%;
}
.email-from {
width: 15%;
}
.email-to {
width: 15%;
}
.email-sentat {
width: 10%;
}
.email-actions {
width: 7%;
}
}

116
cmd/fake-smtp/smtp.go Normal file
View File

@ -0,0 +1,116 @@
package main
import (
"context"
"io"
"log"
"os"
"forge.cadoles.com/wpetit/fake-smtp/internal/command"
"forge.cadoles.com/wpetit/fake-smtp/internal/config"
"github.com/emersion/go-smtp"
"github.com/jhillyerd/enmime"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service"
)
type Backend struct {
ctn *service.Container
username string
password string
}
// Login handles a login command with username and password.
func (b *Backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
if b.username != "" && username != b.username {
return nil, errors.New("invalid username")
}
if b.password != "" && password != b.password {
return nil, errors.New("invalid password")
}
ctx := context.WithValue(context.Background(), container.KeyServiceContainer, b.ctn)
return &Session{ctx, b.ctn, username}, nil
}
// AnonymousLogin requires clients to authenticate using SMTP AUTH before sending emails
func (b *Backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
if b.username != "" || b.password != "" {
return nil, smtp.ErrAuthRequired
}
return nil, nil
}
// A Session is returned after successful login.
type Session struct {
ctx context.Context
ctn *service.Container
username string
}
func (s *Session) Mail(from string, opts smtp.MailOptions) error {
return nil
}
func (s *Session) Rcpt(to string) error {
return nil
}
func (s *Session) Data(r io.Reader) error {
env, err := enmime.ReadEnvelope(r)
if err != nil {
return errors.Wrap(err, "could not read envelope")
}
bus, err := cqrs.From(s.ctn)
if err != nil {
return errors.Wrap(err, "could not retrieve cqrs service")
}
cmd := &command.StoreEmailRequest{
Envelope: env,
}
if _, err := bus.Exec(s.ctx, cmd); err != nil {
return errors.Wrap(err, "could not exec command")
}
return nil
}
func (s *Session) Reset() {}
func (s *Session) Logout() error {
return nil
}
func startSMTPServer(conf *config.Config, ctn *service.Container) {
be := &Backend{
ctn: ctn,
username: conf.SMTP.Username,
password: conf.SMTP.Password,
}
s := smtp.NewServer(be)
s.Addr = conf.SMTP.Address
s.Domain = conf.SMTP.Domain
s.ReadTimeout = conf.SMTP.ReadTimeout
s.WriteTimeout = conf.SMTP.WriteTimeout
s.MaxMessageBytes = conf.SMTP.MaxMessageBytes
s.MaxRecipients = conf.SMTP.MaxRecipients
s.AllowInsecureAuth = conf.SMTP.AllowInsecureAuth
if conf.SMTP.Debug {
s.Debug = os.Stdout
}
log.Println("starting server at", s.Addr)
if err := s.ListenAndServe(); err != nil {
log.Fatal(err)
}
}

View File

@ -0,0 +1,20 @@
{{define "base"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" . -}}{{- end}}</title>
{{- block "head_style" . -}}
<link rel="stylesheet" href="/css/main.css" />
{{end}}
{{- block "head_script" . -}}
<script type="text/javascript" src="/main.js"></script>
{{end}}
</head>
<body>
{{- block "body" . -}}{{- end -}}
{{- block "body_script" . -}}{{end}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,23 @@
{{define "flash"}}
<div class="flash has-margin-top-small has-margin-bottom-small">
{{- range .Flashes -}}
{{- if eq .Type "error" -}}
{{template "flash_message" map "Title" "Erreur" "MessageClass" "is-danger" "Message" .Message }}
{{- else if eq .Type "warn" -}}
{{template "flash_message" map "Title" "Attention" "MessageClass" "is-warning" "Message" .Message }}
{{- else if eq .Type "success" -}}
{{template "flash_message" map "Title" "Succès" "MessageClass" "is-success" "Message" .Message }}
{{- else -}}
{{template "flash_message" map "Title" "Information" "MessageClass" "is-info" "Message" .Message }}
{{- end -}}
{{- end -}}
</div>
{{end}}
{{define "flash_message" -}}
<div class="message {{.MessageClass}}">
<div class="message-body">
<span class="has-text-weight-bold">{{.Title}}</span> {{.Message}}
</div>
</div>
{{- end}}

View File

@ -0,0 +1,7 @@
{{define "footer"}}
<p class="has-margin-top-small has-text-right is-size-7 has-text-grey">
Version: {{ .BuildInfo.ProjectVersion }} -
Réf.: {{ .BuildInfo.GitRef }} -
Date de construction: {{ .BuildInfo.BuildDate }}
</p>
{{end}}

View File

@ -0,0 +1,20 @@
{{define "header"}}
<div class="columns is-mobile">
<div class="column is-narrow">
<h1 class="is-size-3 title">
<a href="/" rel="Inbox" class="has-text-grey-dark">
{{if or .Emails .Email}}
📬
{{else}}
📭
{{end}}
Fake<span class="has-text-grey">SMTP</span>
</a>
</h1>
</div>
<div class="column"></div>
<div class="column is-narrow">
{{block "header_buttons" .}}{{end}}
</div>
</div>
{{end}}

View File

@ -0,0 +1,141 @@
{{define "title"}}Email - FakeSMTP{{end}}
{{define "header_buttons"}}
<button class="button is-danger"
data-controller="restful"
data-restful-endpoint="./{{ .Email.ID }}"
data-restful-method="DELETE"
data-restful-redirect="../">
🗑️ Delete
</button>
{{end}}
{{define "body"}}
<section class="home is-fullheight section">
<div class="container is-fluid">
{{template "header" .}}
<div class="columns">
<div class="column">
<div class="columns">
<div class="column">
<h4 class="title is-size-4">Email</h4>
{{template "email_head" .}}
</div>
{{if .Email.Attachments}}
<div class="column is-narrow">
<h4 class="title is-size-4">Attachments ({{len .Email.Attachments}})</h4>
<ul>
{{ $email := .Email }}
{{range $i, $a := .Email.Attachments}}
<li><a href="{{ $email.ID }}/attachments/{{ $i }}" download="{{ $a.Name }}">{{ $a.Name }}</a></li>
{{end}}
</ul>
</div>
{{end}}
</div>
<div data-controller="tabs">
<div class="tabs">
<ul>
<li data-action="click->tabs#openTab" data-target="tabs.tab" data-tabs-name="html" {{if .Email.HTML}}class="is-active"{{end}}><a>HTML</a></li>
<li data-action="click->tabs#openTab" data-target="tabs.tab" data-tabs-name="text" {{if not .Email.HTML}}class="is-active"{{end}}><a>Text</a></li>
<li data-action="click->tabs#openTab" data-target="tabs.tab" data-tabs-name="headers"><a>Headers</a></li>
</ul>
</div>
<iframe data-target="tabs.tabContent" data-tabs-for="html"
frameborder="0"
data-controller="iframe"
data-action="load->iframe#onLoad"
style="width:100%;{{if not .Email.HTML}}display:none;{{end}}"
src="{{ .Email.ID }}/html">
</iframe>
<div data-target="tabs.tabContent" data-tabs-for="text" style="{{if .Email.HTML}}display:none;{{end}}width:100%;overflow:hidden;">
<pre style="white-space:pre-line;">{{ .Email.Text }}</pre>
</div>
<div data-target="tabs.tabContent" data-tabs-for="headers" style="display:none">
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<tr>
<thead>
<tbody>
{{range $k, $v := .Email.Headers}}
<tr>
<td><code>{{ $k }}</code></td>
<td>
{{range $v}}
<code>{{ . }}</code>&nbsp;
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<hr />
</div>
</div>
</div>
{{template "footer" .}}
</div>
</section>
{{end}}
{{define "email_head"}}
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">From</h5>
</div>
<div class="column">
{{template "email_adresses" .Email.From}}
</div>
</div>
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">To</h5>
</div>
<div class="column">
{{template "email_adresses" .Email.To}}
</div>
</div>
{{if .Email.Cc }}
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">Cc</h5>
</div>
<div class="column">
{{template "email_adresses" .Email.Cc}}
</div>
</div>
{{end}}
{{if .Email.Cci }}
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">Cci</h5>
</div>
<div class="column">
{{template "email_adresses" .Email.Cci}}
</div>
</div>
{{end}}
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">Subject</h5>
</div>
<div class="column">
<p class="is-size-5">{{.Email.Subject}}</p>
</div>
</div>
{{end}}
{{define "email_adresses"}}
{{- range .}}
<span class="tag">
{{- if .Name -}}
{{.Name}} <{{.Address}}>
{{- else -}}
{{.Address}}
{{- end -}}
</span>
{{- end -}}
{{end}}
{{template "base" .}}

View File

@ -0,0 +1,61 @@
{{define "title"}}Inbox - FakeSMTP{{end}}
{{define "header_buttons"}}
<button
data-controller="restful"
data-restful-endpoint="/emails"
data-restful-method="DELETE"
class="button is-danger">
🗑️ Clear
</button>
{{end}}
{{define "body"}}
<section class="home is-fullheight section">
<div class="container is-fluid">
{{template "header" .}}
<div>
<table class="inbox table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th class="email-subject">Subject</th>
<th class="email-from">From</th>
<th class="email-to">Recipients</th>
<th class="email-sentat">Date</th>
<th class="email-actions"></th>
</tr>
</thead>
<tbody>
{{range .Emails}}
<tr>
<td class="email-subject"><div>{{ .Subject }}</div></td>
<td class="email-from">
{{range .From}}
<span class="tag">{{ .Address }}</span>
{{end}}
</td>
<td class="email-to">
{{range .To}}
<span class="tag">{{ .Address }}</span>
{{end}}
</td>
<td class="email-sentat">
<span class="is-size-7">{{ .SentAt.Format "02/01/2006 15:04:05"}}</span>
</td>
<td class="email-actions">
<div class="buttons is-right">
<a href="./emails/{{ .ID }}" class="button is-small is-link">👁️ See</a>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="5" class="has-text-centered">No email yet.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "footer" .}}
</div>
</section>
{{end}}
{{template "base" .}}