Fresh start

This commit is contained in:
Kailash Nadh 2018-10-25 19:21:47 +05:30
commit 3ab21383b1
68 changed files with 18559 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
frontend/node_modules/
frontend/my/node_modules/
frontend/.cache/
frontend/yarn.lock
.vscode/
config.toml

16
Makefile Normal file
View File

@ -0,0 +1,16 @@
BIN := listmonk
HASH := $(shell git rev-parse --short HEAD)
COMMIT_DATE := $(shell git show -s --format=%ci ${HASH})
BUILD_DATE := $(shell date '+%Y-%m-%d %H:%M:%S')
VERSION := ${HASH} (${COMMIT_DATE})
build:
go build -o ${BIN} -ldflags="-X 'main.buildVersion=${VERSION}' -X 'main.buildDate=${BUILD_DATE}'"
test:
go test
clean:
go clean
- rm -f ${BIN}

13
admin.go Normal file
View File

@ -0,0 +1,13 @@
package main
import (
"net/http"
"github.com/labstack/echo"
)
// handleGetStats returns a collection of general statistics.
func handleGetStats(c echo.Context) error {
app := c.Get("app").(*App)
return c.JSON(http.StatusOK, okResp{app.Runner.GetMessengerNames()})
}

443
campaigns.go Normal file
View File

@ -0,0 +1,443 @@
package main
import (
"bytes"
"database/sql"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"time"
"github.com/asaskevich/govalidator"
"github.com/knadh/listmonk/models"
"github.com/knadh/listmonk/runner"
"github.com/labstack/echo"
"github.com/lib/pq"
uuid "github.com/satori/go.uuid"
null "gopkg.in/volatiletech/null.v6"
)
// campaignReq is a wrapper over the Campaign model.
type campaignReq struct {
models.Campaign
MessengerID string `json:"messenger"`
Lists pq.Int64Array `json:"lists"`
}
type campaignStats struct {
ID int `db:"id" json:"id"`
Status string `db:"status" json:"status"`
ToSend int `db:"to_send" json:"to_send"`
Sent int `db:"sent" json:"sent"`
Started null.Time `db:"started_at" json:"started_at"`
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
Rate float64 `json:"rate"`
}
var regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
// handleGetCampaigns handles retrieval of campaigns.
func handleGetCampaigns(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams())
out models.Campaigns
id, _ = strconv.Atoi(c.Param("id"))
status = c.FormValue("status")
single = false
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
// Fetch one list.
if id > 0 {
single = true
}
err := app.Queries.GetCampaigns.Select(&out, id, status, pg.Offset, pg.Limit)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaigns: %s", pqErrMsg(err)))
} else if single && len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
} else if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
for i := 0; i < len(out); i++ {
// Replace null tags.
if out[i].Tags == nil {
out[i].Tags = make(pq.StringArray, 0)
}
if noBody {
out[i].Body = ""
}
}
if single {
return c.JSON(http.StatusOK, okResp{out[0]})
}
return c.JSON(http.StatusOK, okResp{out})
}
// handlePreviewTemplate renders the HTML preview of a campaign body.
func handlePreviewCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
camps models.Campaigns
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
err := app.Queries.GetCampaigns.Select(&camps, id, "", 0, 1)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
} else if len(camps) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
var (
camp = camps[0]
sub models.Subscriber
)
// Get a random subscriber from the campaign.
if err := app.Queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil {
if err == sql.ErrNoRows {
// There's no subscriber. Mock one.
sub = models.Subscriber{
Name: "Dummy Subscriber",
Email: "dummy@subscriber.com",
UUID: "00000000-0000-0000-0000-000000000000",
Status: models.SubscriberStatusEnabled,
}
} else {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
}
}
// Compile the template.
tpl, err := runner.CompileMessageTemplate(`{{ template "content" . }}`, camp.Body)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err))
}
// Render the message body.
var out = bytes.Buffer{}
if err := tpl.ExecuteTemplate(&out,
runner.BaseTPL,
runner.Message{Campaign: &camp, Subscriber: &sub, UnsubscribeURL: "#dummy"}); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error executing template: %v", err))
}
return c.HTML(http.StatusOK, out.String())
}
// handleCreateCampaign handles campaign creation.
// Newly created campaigns are always drafts.
func handleCreateCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
o campaignReq
)
if err := c.Bind(&o); err != nil {
return err
}
// Validate.
if err := validateCampaignFields(o); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if !app.Runner.HasMessenger(o.MessengerID) {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Unknown messenger %s", o.MessengerID))
}
// Insert and read ID.
var newID int
if err := app.Queries.CreateCampaign.Get(&newID,
uuid.NewV4(),
o.Name,
o.Subject,
o.FromEmail,
o.Body,
o.ContentType,
o.SendAt,
pq.StringArray(normalizeTags(o.Tags)),
"email",
o.TemplateID,
o.Lists,
); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest,
"There aren't any subscribers in the target lists to create the campaign.")
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating campaign: %v", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", newID))
return handleGetCampaigns(c)
}
// handleUpdateCampaign handles campaign modification.
// Campaigns that are done cannot be modified.
func handleUpdateCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
var cm models.Campaign
if err := app.Queries.GetCampaigns.Get(&cm, id, "", 0, 1); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
}
if isCampaignalMutable(cm.Status) {
return echo.NewHTTPError(http.StatusBadRequest,
"Cannot update a running or a finished campaign.")
}
// Incoming params.
var o campaignReq
if err := c.Bind(&o); err != nil {
return err
}
if err := validateCampaignFields(o); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
res, err := app.Queries.UpdateCampaign.Exec(cm.ID,
o.Name,
o.Subject,
o.FromEmail,
o.Body,
o.ContentType,
o.SendAt,
pq.StringArray(normalizeTags(o.Tags)),
o.TemplateID,
o.Lists)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
return handleGetCampaigns(c)
}
// handleUpdateCampaignStatus handles campaign status modification.
func handleUpdateCampaignStatus(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
var cm models.Campaign
if err := app.Queries.GetCampaigns.Get(&cm, id, "", 0, 1); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
}
// Incoming params.
var o campaignReq
if err := c.Bind(&o); err != nil {
return err
}
errMsg := ""
switch o.Status {
case models.CampaignStatusDraft:
if cm.Status != models.CampaignStatusScheduled {
errMsg = "Only scheduled campaigns can be saved as drafts"
}
case models.CampaignStatusScheduled:
if cm.Status != models.CampaignStatusDraft {
errMsg = "Only draft campaigns can be scheduled"
}
if !cm.SendAt.Valid {
errMsg = "Campaign needs a `send_at` date to be scheduled"
}
case models.CampaignStatusRunning:
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
errMsg = "Only paused campaigns and drafts can be started"
}
case models.CampaignStatusPaused:
if cm.Status != models.CampaignStatusRunning {
errMsg = "Only active campaigns can be paused"
}
case models.CampaignStatusCancelled:
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
errMsg = "Only active campaigns can be cancelled"
}
}
if len(errMsg) > 0 {
return echo.NewHTTPError(http.StatusBadRequest, errMsg)
}
res, err := app.Queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
return handleGetCampaigns(c)
}
// handleDeleteCampaign handles campaign deletion.
// Only scheduled campaigns that have not started yet can be deleted.
func handleDeleteCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
var cm models.Campaign
if err := app.Queries.GetCampaigns.Get(&cm, id, "", 0, 1); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
}
// Only scheduled campaigns can be deleted.
if cm.Status != models.CampaignStatusDraft &&
cm.Status != models.CampaignStatusScheduled {
return echo.NewHTTPError(http.StatusBadRequest,
"Only campaigns that haven't been started can be deleted.")
}
if _, err := app.Queries.DeleteCampaign.Exec(cm.ID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error deleting campaign: %v", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleGetRunningCampaignStats returns stats of a given set of campaign IDs.
func handleGetRunningCampaignStats(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []campaignStats
)
if err := app.Queries.GetCampaignStats.Select(&out, models.CampaignStatusRunning); err != nil {
if err == sql.ErrNoRows {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign stats: %s", pqErrMsg(err)))
} else if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
// Compute rate.
for i, c := range out {
if c.Started.Valid && c.UpdatedAt.Valid {
diff := c.UpdatedAt.Time.Sub(c.Started.Time).Minutes()
if diff > 0 {
out[i].Rate = float64(c.Sent) / diff
t := float64(c.ToSend)
if out[i].Rate > t {
out[i].Rate = t
}
}
}
}
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetCampaignMessengers returns the list of registered messengers.
func handleGetCampaignMessengers(c echo.Context) error {
app := c.Get("app").(*App)
return c.JSON(http.StatusOK, okResp{app.Runner.GetMessengerNames()})
}
// validateCampaignFields validates incoming campaign field values.
func validateCampaignFields(c campaignReq) error {
if !regexFromAddress.Match([]byte(c.FromEmail)) {
if !govalidator.IsEmail(c.FromEmail) {
return errors.New("invalid `from_email`")
}
}
if !govalidator.IsByteLength(c.Name, 1, stdInputMaxLen) {
return errors.New("invalid length for `name`")
}
if !govalidator.IsByteLength(c.Subject, 1, stdInputMaxLen) {
return errors.New("invalid length for `subject`")
}
// if !govalidator.IsByteLength(c.Body, 1, bodyMaxLen) {
// return errors.New("invalid length for `body`")
// }
// If there's a "send_at" date, it should be in the future.
if c.SendAt.Valid {
if c.SendAt.Time.Before(time.Now()) {
return errors.New("`send_at` date should be in the future")
}
}
return nil
}
// isCampaignalMutable tells if a campaign's in a state where it's
// properties can be mutated.
func isCampaignalMutable(status string) bool {
return status == models.CampaignStatusRunning ||
status == models.CampaignStatusCancelled ||
status == models.CampaignStatusFinished
}

73
config.toml.sample Normal file
View File

@ -0,0 +1,73 @@
# Application.
[app]
# Interface and port where the app will run its webserver.
address = "0.0.0.0:9000"
# Root to the listmonk installation that'll be used in the e-mails for linking to
# images, the unsubscribe URL etc.
root = "http://listmonk.mysite.com"
# The default 'from' e-mail for outgoing e-mail campaigns.
from_email = "listmonk <from@mail.com>"
# Path to the uploads directory where media will be uploaded.
upload_path = "uploads"
# Upload URI that's visible to the outside world. The media
# uploaded to upload_path will be made available publicly
# under this URI, for instance, list.yoursite.com/uploads.
upload_uri = "/uploads"
# Directory where the app's static assets are stored (index.html, ./static etc.)
asset_path = "frontend/my/build"
# Maximum concurrent workers that will attempt to send messages
# simultaneously. This should depend on the number of CPUs the
# machine has and also the number of simultaenous e-mails the
# mail server will
concurrency = 100
# Database.
[db]
host = "localhost"
port = 5432
user = "listmonk"
password = ""
database = "listmonk"
# TQekh4quVgGc3HQ
# SMTP servers.
[smtp]
[smtp.my0]
enabled = true
host = "my.smtp.server"
port = "25"
# cram or plain.
auth_protocol = "cram"
username = "xxxxx"
password = ""
# Maximum time (milliseconds) to wait per e-mail push.
send_timeout = 5000
# Maximum concurrent connections to the SMTP server.
max_conns = 10
[smtp.postal]
enabled = false
host = "my.smtp.server2"
port = "25"
# cram or plain.
auth_protocol = "plain"
username = "xxxxx"
password = ""
# Maximum time (milliseconds) to wait per e-mail push.
send_timeout = 5000
# Maximum concurrent connections to the SMTP server.
max_conns = 10

52
default-template.html Normal file
View File

@ -0,0 +1,52 @@
<!doctype html>
<html>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<style>
body {
background: #F0F1F3;
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
font-size: 15px;
line-height: 26px;
color: #444;
}
.wrap {
background: #fff;
padding: 30px;
max-width: 525px;
margin: 60px auto 30px auto;
border-radius: 5px;
}
.footer {
text-align: center;
font-size: 12px;
color: #888;
}
.footer a {
color: #888;
}
a {
color: #7f2aff;
}
a:hover {
color: #111;
}
</style>
</html>
<body>
<div class="wrap">
{{ template "content" . }}
</div>
<div class="footer">
<p>Don't want to receive these e-mails? <a href="{{ .UnsubscribeURL }}">Unsubscribe</a></p>
<p>Powered by <a href="https://listmonk.app" target="_blank">listmonk</a></p>
</div>
</body>
</html>

4
frontend/.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": ["env", "react"],
"plugins": [["transform-react-jsx", { "pragma": "h" }]]
}

21
frontend/my/.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

2444
frontend/my/README.md Normal file

File diff suppressed because it is too large Load Diff

26
frontend/my/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "my",
"version": "0.1.0",
"private": true,
"dependencies": {
"antd": "^3.6.5",
"axios": "^0.18.0",
"dayjs": "^1.7.5",
"react": "^16.4.1",
"react-dom": "^16.4.1",
"react-quill": "^1.3.1",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"react-scripts": "1.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
},
"theme": "./src/theme.js",
"eslintConfig": {
"extends": "react-app"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1 @@
<svg viewbox="0 0 18 18"><rect class="ql-stroke" height="10" width="12" x="3" y="4"></rect><circle class="ql-fill" cx="6" cy="7" r="1"></circle><polyline class="ql-even ql-fill" points="5 12 5 11 7 9 8 10 11 7 13 9 13 12 5 12"></polyline></svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

140
frontend/my/src/App.js Normal file
View File

@ -0,0 +1,140 @@
import React from 'react'
import Utils from './utils'
import { BrowserRouter } from 'react-router-dom'
import { notification } from "antd"
import axios from 'axios'
import qs from 'qs'
import Layout from './Layout'
import * as cs from './constants'
/*
App acts as a an "automagic" wrapper for all sub components. It is also the central
store for data required by various child components. In addition, all HTTP requests
are fired through App.requests(), where successful responses are set in App's state
for child components to access via this.props.data[type]. The structure is as follows:
App.state.data = {
"lists": [],
"subscribers": []
// etc.
}
A number of assumptions are made here for the "automagic" behaviour.
1. All responses to resources return lists
2. All PUT, POST, DELETE requests automatically append /:id to the API URIs.
*/
class App extends React.PureComponent {
models = [cs.ModelUsers,
cs.ModelSubscribers,
cs.ModelLists,
cs.ModelCampaigns,
cs.ModelTemplates]
state = {
// Initialize empty states.
reqStates: this.models.reduce((map, obj) => (map[obj] = cs.StatePending, map), {}),
data: this.models.reduce((map, obj) => (map[obj] = [], map), {}),
modStates: {}
}
componentDidMount = () => {
axios.defaults.paramsSerializer = params => {
return qs.stringify(params, {arrayFormat: "repeat"});
}
}
// modelRequest is an opinionated wrapper for model specific HTTP requests,
// including setting model states.
modelRequest = async (model, route, method, params) => {
let url = route
// Replace :params in the URL with params in the array.
let uriParams = route.match(/:([a-z0-9\-_]+)/ig)
if(uriParams && uriParams.length > 0) {
uriParams.forEach((p) => {
let pName = p.slice(1) // Lose the ":" prefix
if(params && params.hasOwnProperty(pName)) {
url = url.replace(p, params[pName])
}
})
}
this.setState({ reqStates: { ...this.state.reqStates, [model]: cs.StatePending } })
try {
let req = {
method: method,
url: url,
}
if (method === cs.MethodGet || method === cs.MethodDelete) {
req.params = params ? params : {}
} else {
req.data = params ? params : {}
}
let res = await axios(req)
this.setState({ reqStates: { ...this.state.reqStates, [model]: cs.StateDone } })
// If it's a GET call, set the response as the data state.
if (method === cs.MethodGet) {
this.setState({ data: { ...this.state.data, [model]: res.data.data } })
}
return res
} catch (e) {
// If it's a GET call, throw a global notification.
if (method === cs.MethodGet) {
notification["error"]({ message: "Error fetching data", description: Utils.HttpError(e).message, duration: 0 })
}
// Set states and show the error on the layout.
this.setState({ reqStates: { ...this.state.reqStates, [model]: cs.StateDone } })
throw Utils.HttpError(e)
}
}
// request is a wrapper for generic HTTP requests.
request = async (url, method, params, headers) => {
if (params && params.hasOwnProperty("id") && typeof params["id"] === "number") {
url += "/" + params["id"]
}
this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StatePending } })
try {
let req = {
method: method,
url: url,
headers: headers ? headers : {}
}
if(method === cs.MethodGet || method === cs.MethodDelete) {
req.params = params ? params : {}
} else {
req.data = params ? params : {}
}
let res = await axios(req)
this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StateDone } })
return res
} catch (e) {
this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StateDone } })
throw Utils.HttpError(e)
}
}
render() {
return (
<BrowserRouter>
<Layout modelRequest={ this.modelRequest }
request={ this.request }
reqStates={ this.state.reqStates }
data={ this.state.data } />
</BrowserRouter>
)
}
}
export default App

481
frontend/my/src/Campaign.js Normal file
View File

@ -0,0 +1,481 @@
import React from "react"
import { Modal, Tabs, Row, Col, Form, Switch, Select, Radio, Tag, Input, Button, Icon, Spin, DatePicker, Popconfirm, notification } from "antd"
import * as cs from "./constants"
import Media from "./Media"
import moment from 'moment'
import ReactQuill from "react-quill"
import Delta from "quill-delta"
import "react-quill/dist/quill.snow.css"
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
}
const formItemTailLayout = {
wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
}
class Editor extends React.PureComponent {
state = {
editor: null,
quill: null,
rawInput: null,
selContentType: "richtext",
contentType: "richtext",
body: "",
}
quillModules = {
toolbar: {
container: [
[{"header": [1, 2, 3, false] }],
["bold", "italic", "underline", "strike", "blockquote", "code"],
[{ "color": [] }, { "background": [] }, { 'size': [] }],
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}],
[{"align": ""}, { "align": "center" }, { "align": "right" }, { "align": "justify" }],
["link", "gallery"],
["clean", "font"]
],
handlers: {
"gallery": () => {
this.props.toggleMedia()
}
}
}
}
componentDidMount = () => {
// The editor component will only load once the individual campaign metadata
// has loaded, i.e., record.body is guaranteed to be available here.
if(this.props.record && this.props.record.id) {
this.setState({
body: this.props.record.body,
contentType: this.props.record.content_type,
selContentType: this.props.record.content_type
})
}
}
// Custom handler for inserting images from the media popup.
insertMedia = (uri) => {
const quill = this.state.quill.getEditor()
let range = quill.getSelection(true);
quill.updateContents(new Delta()
.retain(range.index)
.delete(range.length)
.insert({ image: uri })
, null);
}
handleSelContentType = (_, e) => {
this.setState({ selContentType: e.props.value })
}
handleSwitchContentType = () => {
this.setState({ contentType: this.state.selContentType })
if(!this.state.quill || !this.state.quill.editor || !this.state.rawInput) {
return
}
// Switching from richtext to html.
let body = ""
if(this.state.selContentType === "html") {
body = this.state.quill.editor.container.firstChild.innerHTML
this.state.rawInput.value = body
} else if(this.state.selContentType === "richtext") {
body = this.state.rawInput.value
this.state.quill.editor.clipboard.dangerouslyPasteHTML(body, "raw")
}
this.props.setContent(this.state.selContentType, body)
}
render() {
return (
<div>
<header className="header">
{ !this.props.formDisabled &&
<Row>
<Col span={ 20 }>
<div className="content-type">
<p>Content format</p>
<Select name="content_type" onChange={ this.handleSelContentType } style={{ minWidth: 200 }}
value={ this.state.selContentType }>
<Select.Option value="richtext">Rich Text</Select.Option>
<Select.Option value="html">Raw HTML</Select.Option>
</Select>
{ this.state.contentType !== this.state.selContentType &&
<div className="actions">
<Popconfirm title="The content may lose its formatting. Are you sure?"
onConfirm={ this.handleSwitchContentType }>
<Button>
<Icon type="save" /> Switch format
</Button>
</Popconfirm>
</div>}
</div>
</Col>
<Col span={ 4 }></Col>
</Row>
}
</header>
<ReactQuill
readOnly={ this.props.formDisabled }
style={{ display: this.state.contentType === "richtext" ? "block" : "none" }}
modules={ this.quillModules }
defaultValue={ this.props.record.body }
ref={ (o) => {
this.setState({ quill: o })
}}
onChange={ () => {
if(!this.state.quill) {
return
}
this.props.setContent(this.state.contentType, this.state.quill.editor.root.innerHTML)
} }
/>
<Input.TextArea
readOnly={ this.props.formDisabled }
placeholder="Your message here"
style={{ display: this.state.contentType === "html" ? "block" : "none" }}
id="html-body"
rows={ 10 }
autosize={ { minRows: 2, maxRows: 10 } }
defaultValue={ this.props.record.body }
ref={ (o) => {
if(!o) {
return
}
this.setState({ rawInput: o.textAreaRef })
}}
onChange={ (e) => {
this.props.setContent(this.state.contentType, e.target.value)
}}
/>
</div>
)
}
}
class TheFormDef extends React.PureComponent {
state = {
editorVisible: false,
sendLater: false
}
componentWillReceiveProps(nextProps) {
const has = nextProps.isSingle && nextProps.record.send_at !== null
if(!has) {
return
}
if(this.state.sendLater !== has) {
this.setState({ sendLater: has })
}
}
validateEmail = (rule, value, callback) => {
if(!value.match(/(.+?)\s<(.+?)@(.+?)>/)) {
return callback("Format should be: Your Name <email@address.com>")
}
callback()
}
handleSendLater = (e) => {
this.setState({ sendLater: e })
}
// Handle create / edit form submission.
handleSubmit = (e) => {
e.preventDefault()
this.props.form.validateFields((err, values) => {
if (err) {
return
}
if(!values.tags) {
values.tags = []
}
values.body = this.props.body
values.content_type = this.props.contentType
// Create a new campaign.
if(!this.props.isSingle) {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.CreateCampaign, cs.MethodPost, values).then((resp) => {
notification["success"]({ placement: "topRight",
message: "Campaign created",
description: `"${values["name"]}" created` })
this.props.route.history.push(cs.Routes.ViewCampaign.replace(":id", resp.data.data.id))
this.props.fetchRecord(resp.data.data.id)
this.props.setCurrentTab("content")
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
} else {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.UpdateCampaign, cs.MethodPut, { ...values, id: this.props.record.id }).then((resp) => {
notification["success"]({ placement: "topRight",
message: "Campaign updated",
description: `"${values["name"]}" updated` })
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
})
}
render() {
const { record } = this.props;
const { getFieldDecorator } = this.props.form
let subLists = []
if(this.props.isSingle && record.lists) {
subLists = record.lists.map((v) => { return v.id !== 0 ? v.id : null }).filter(v => v !== null)
}
return (
<div>
<Form onSubmit={ this.handleSubmit }>
<Form.Item {...formItemLayout} label="Campaign name">
{getFieldDecorator("name", {
extra: "This is internal and will not be visible to subscribers",
initialValue: record.name,
rules: [{ required: true }]
})(<Input disabled={ this.props.formDisabled }autoFocus maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="Subject">
{getFieldDecorator("subject", {
initialValue: record.subject,
rules: [{ required: true }]
})(<Input disabled={ this.props.formDisabled } maxLength="500" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="From address">
{getFieldDecorator("from_email", {
initialValue: record.from_email,
rules: [{ required: true }, { validator: this.validateEmail }]
})(<Input disabled={ this.props.formDisabled } placeholder="Company Name <email@company.com>" maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to">
{getFieldDecorator("lists", { initialValue: subLists, rules: [{ required: true }] })(
<Select disabled={ this.props.formDisabled } mode="multiple">
{[...this.props.data[cs.ModelLists]].map((v, i) =>
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option>
)}
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Template" extra="Template">
{getFieldDecorator("template_id", { initialValue: record.template_id, rules: [{ required: true }] })(
<Select disabled={ this.props.formDisabled }>
{[...this.props.data[cs.ModelTemplates]].map((v, i) =>
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option>
)}
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags">
{getFieldDecorator("tags", { initialValue: record.tags })(
<Select disabled={ this.props.formDisabled } mode="tags"></Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Messenger">
{getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })(
<Radio.Group className="messengers">
{[...this.props.messengers].map((v, i) =>
<Radio disabled={ this.props.formDisabled } value={v} key={v}>{ v }</Radio>
)}
</Radio.Group>
)}
</Form.Item>
<hr />
<Form.Item {...formItemLayout} label="Send later?">
<Row>
<Col span={ 2 }>
{getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })(
<Switch disabled={ this.props.formDisabled }
checked={ this.state.sendLater }
onChange={ this.handleSendLater } />
)}
</Col>
<Col span={ 12 }>
{this.state.sendLater && getFieldDecorator("send_at",
{ initialValue: (record && typeof(record.send_at) === "string") ? moment(record.send_at) : moment(new Date()).add(1, "days").startOf("day") })(
<DatePicker
disabled={ this.props.formDisabled }
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="Select a date and time"
/>
)}
</Col>
</Row>
</Form.Item>
{ !this.props.formDisabled &&
<Form.Item {...formItemTailLayout}>
<Button htmlType="submit" type="primary">
<Icon type="save" /> { !this.props.isSingle ? "Continue" : "Save changes" }
</Button>
</Form.Item>
}
</Form>
{ this.props.isSingle &&
<div>
<hr />
<Form.Item {...formItemLayout} label="Send test e-mails" extra="Hit Enter after typing an e-mail to add multiple emails">
<Select mode="tags" style={{ minWidth: 320 }}></Select>
<div><Button htmlType="submit"><Icon type="mail" /> Send test</Button></div>
</Form.Item>
</div>
}
</div>
)
}
}
const TheForm = Form.create()(TheFormDef)
class Campaign extends React.PureComponent {
state = {
campaignID: this.props.route.match.params ? parseInt(this.props.route.match.params.campaignID, 10) : 0,
record: {},
contentType: "richtext",
messengers: [],
body: "",
currentTab: "form",
editor: null,
loading: true,
mediaVisible: false,
formDisabled: false
}
componentDidMount = () => {
// Fetch lists.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
// Fetch templates.
this.props.modelRequest(cs.ModelTemplates, cs.Routes.GetTemplates, cs.MethodGet)
// Fetch messengers.
this.props.request(cs.Routes.GetCampaignMessengers, cs.MethodGet).then((r) => {
this.setState({ messengers: r.data.data, loading: false })
})
// Fetch campaign.
if(this.state.campaignID) {
this.fetchRecord(this.state.campaignID)
}
}
fetchRecord = (id) => {
this.props.request(cs.Routes.GetCampaigns, cs.MethodGet, { id: id }).then((r) => {
const record = r.data.data
this.setState({ record: record, loading: false })
// The form for non draft and scheduled campaigns should be locked.
if(record.status !== cs.CampaignStatusDraft &&
record.status !== cs.CampaignStatusScheduled) {
this.setState({ formDisabled: true })
}
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
setContent = (contentType, body) => {
this.setState({ contentType: contentType, body: body })
}
toggleMedia = () => {
this.setState({ mediaVisible: !this.state.mediaVisible })
}
setCurrentTab = (tab) => {
this.setState({ currentTab: tab })
}
render() {
return (
<section className="content campaign">
<Row>
<Col span={22}>
{ !this.state.record.id && <h1>Create a campaign</h1> }
{ this.state.record.id &&
<h1>
<Tag color={ cs.CampaignStatusColors[this.state.record.status] }>{ this.state.record.status }</Tag>
{ this.state.record.name }
</h1>
}
</Col>
<Col span={2}>
</Col>
</Row>
<br />
<Tabs type="card"
activeKey={ this.state.currentTab }
onTabClick={ (t) => {
this.setState({ currentTab: t })
}}>
<Tabs.TabPane tab="Campaign" key="form">
<Spin spinning={ this.state.loading }>
<TheForm { ...this.props }
record={ this.state.record }
isSingle={ this.state.record.id ? true : false }
messengers={ this.state.messengers }
body={ this.state.body }
contentType={ this.state.contentType }
formDisabled={ this.state.formDisabled }
fetchRecord={ this.fetchRecord }
setCurrentTab={ this.setCurrentTab }
/>
</Spin>
</Tabs.TabPane>
<Tabs.TabPane tab="Content" disabled={ this.state.record.id ? false : true } key="content">
<Editor { ...this.props }
ref={ (e) => {
// Take the editor's reference and save it in the state
// so that it's insertMedia() function can be passed to <Media />
this.setState({ editor: e })
}}
isSingle={ this.state.record.id ? true : false }
record={ this.state.record }
visible={ this.state.editorVisible }
toggleMedia={ this.toggleMedia }
setContent={ this.setContent }
formDisabled={ this.state.formDisabled }
/>
<div className="content-actions">
<p><Button icon="search">Preview</Button></p>
</div>
</Tabs.TabPane>
</Tabs>
<Modal visible={ this.state.mediaVisible } width="900px"
title="Media"
okText={ "Ok" }
onCancel={ this.toggleMedia }
onOk={ this.toggleMedia }>
<Media { ...{ ...this.props,
insertMedia: this.state.editor ? this.state.editor.insertMedia : null,
onCancel: this.toggleMedia,
onOk: this.toggleMedia
} } />
</Modal>
</section>
)
}
}
export default Campaign

View File

@ -0,0 +1,397 @@
import React from "react"
import { Link } from "react-router-dom"
import { Row, Col, Button, Table, Icon, Tooltip, Tag, Popconfirm, Progress, Modal, Select, notification, Input } from "antd"
import dayjs from "dayjs"
import relativeTime from 'dayjs/plugin/relativeTime'
import * as cs from "./constants"
class Campaigns extends React.PureComponent {
defaultPerPage = 20
state = {
formType: null,
pollID: -1,
queryParams: "",
stats: {},
record: null,
cloneName: "",
modalWaiting: false
}
// Pagination config.
paginationOptions = {
hideOnSinglePage: true,
showSizeChanger: true,
showQuickJumper: true,
defaultPageSize: this.defaultPerPage,
pageSizeOptions: ["20", "50", "70", "100"],
position: "both",
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
onChange: (page, perPage) => {
this.fetchRecords({ page: page, per_page: perPage })
},
onShowSizeChange: (page, perPage) => {
this.fetchRecords({ page: page, per_page: perPage })
}
}
constructor(props) {
super(props)
this.columns = [{
title: "Name",
dataIndex: "name",
sorter: true,
width: "30%",
vAlign: "top",
render: (text, record) => {
const out = [];
out.push(
<div className="name" key={`name-${record.id}`}>
<Link to={ `/campaigns/${record.id}` }>{ text }</Link><br />
<span className="text-tiny">{ record.subject }</span>
</div>
)
if(record.tags.length > 0) {
for (let i = 0; i < record.tags.length; i++) {
out.push(<Tag key={`tag-${i}`}>{ record.tags[i] }</Tag>);
}
}
return out
}
},
{
title: "Status",
dataIndex: "status",
className: "status",
width: "10%",
render: (status, record) => {
let color = cs.CampaignStatusColors.hasOwnProperty(status) ? cs.CampaignStatusColors[status] : ""
return (
<div>
<Tag color={color}>{status}</Tag>
{record.send_at &&
<span className="text-tiny date">Scheduled &mdash; { dayjs(record.send_at).format(cs.DateFormat) }</span>
}
</div>
)
}
},
{
title: "Lists",
dataIndex: "lists",
width: "20%",
align: "left",
className: "lists",
render: (lists, record) => {
const out = []
lists.forEach((l) => {
out.push(
<span className="name" key={`name-${l.id}`}><Link to={ `/subscribers/lists/${l.id}` }>{ l.name }</Link></span>
)
})
return out
}
},
{
title: "Stats",
className: "stats",
render: (text, record) => {
if(record.status !== cs.CampaignStatusDraft && record.status !== cs.CampaignStatusScheduled) {
return this.renderStats(record)
}
}
},
{
title: "",
dataIndex: "actions",
className: "actions",
width: "10%",
render: (text, record) => {
return (
<div className="actions">
<Tooltip title="Clone campaign" placement="bottom">
<a role="button" onClick={() => {
let r = { ...record, lists: record.lists.map((i) => { return i.id }) }
this.handleToggleCloneForm(r)
}}><Icon type="copy" /></a>
</Tooltip>
{ ( record.status === cs.CampaignStatusPaused ) &&
<Popconfirm title="Are you sure?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusRunning)}>
<Tooltip title="Resume campaign" placement="bottom"><a role="button"><Icon type="rocket" /></a></Tooltip>
</Popconfirm>
}
{ ( record.status === cs.CampaignStatusRunning ) &&
<Popconfirm title="Are you sure?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusPaused)}>
<Tooltip title="Pause campaign" placement="bottom"><a role="button"><Icon type="pause-circle-o" /></a></Tooltip>
</Popconfirm>
}
{/* Draft with send_at */}
{ ( record.status === cs.CampaignStatusDraft && record.send_at) &&
<Popconfirm title="The campaign will start automatically at the scheduled date and time. Schedule now?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusScheduled) }>
<Tooltip title="Schedule campaign" placement="bottom"><a role="button"><Icon type="clock-circle" /></a></Tooltip>
</Popconfirm>
}
{ ( record.status === cs.CampaignStatusDraft && !record.send_at) &&
<Popconfirm title="Campaign properties cannot be changed once it starts. Start now?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusRunning) }>
<Tooltip title="Start campaign" placement="bottom"><a role="button"><Icon type="rocket" /></a></Tooltip>
</Popconfirm>
}
{ ( record.status === cs.CampaignStatusPaused || record.status === cs.CampaignStatusRunning) &&
<Popconfirm title="Are you sure?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusCancelled)}>
<Tooltip title="Cancel campaign" placement="bottom"><a role="button"><Icon type="close-circle-o" /></a></Tooltip>
</Popconfirm>
}
{ ( record.status === cs.CampaignStatusDraft || record.status === cs.CampaignStatusScheduled ) &&
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}>
<Tooltip title="Delete campaign" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip>
</Popconfirm>
}
</div>
)
}
}]
}
progressPercent(record) {
return Math.round(this.getStatsField("sent", record) / this.getStatsField("to_send", record) * 100, 2)
}
isDone(record) {
return this.getStatsField("status", record) === cs.CampaignStatusFinished ||
this.getStatsField("status", record) === cs.CampaignStatusCancelled
}
// getStatsField returns a stats field value of a given record if it
// exists in the stats state, or the value from the record itself.
getStatsField = (field, record) => {
if(this.state.stats.hasOwnProperty(record.id)) {
return this.state.stats[record.id][field]
}
return record[field]
}
renderStats = (record) => {
let color = cs.CampaignStatusColors.hasOwnProperty(record.status) ? cs.CampaignStatusColors[record.status] : ""
const startedAt = this.getStatsField("started_at", record)
const updatedAt = this.getStatsField("updated_at", record)
const sent = this.getStatsField("sent", record)
const toSend = this.getStatsField("to_send", record)
const isDone = this.isDone(record)
const r = this.getStatsField("rate", record)
const rate = r ? r : 0
return (
<div>
{ !isDone &&
<Progress strokeColor={ color } status="active"
type="line" percent={ this.progressPercent(record) } />
}
<Row><Col className="label" span={10}>Sent</Col><Col span={12}>
{ sent >= toSend &&
<span>{ toSend }</span>
}
{ sent < toSend &&
<span>{ sent } / { toSend }</span>
}
&nbsp;
{ record.status === cs.CampaignStatusRunning &&
<Icon type="loading" style={{ fontSize: 12 }} spin />
}
</Col></Row>
{ rate > 0 &&
<Row><Col className="label" span={10}>Rate</Col><Col span={12}>{ Math.round(rate, 2) } / min</Col></Row>
}
<Row><Col className="label" span={10}>Views</Col><Col span={12}>0</Col></Row>
<Row><Col className="label" span={10}>Clicks</Col><Col span={12}>0</Col></Row>
<br />
<Row><Col className="label" span={10}>Created</Col><Col span={12}>{ dayjs(record.created_at).format(cs.DateFormat) }</Col></Row>
{ startedAt &&
<Row><Col className="label" span={10}>Started</Col><Col span={12}>{ dayjs(startedAt).format(cs.DateFormat) }</Col></Row>
}
{ isDone &&
<Row><Col className="label" span={10}>Ended</Col><Col span={12}>
{ dayjs(updatedAt).format(cs.DateFormat) }
</Col></Row>
}
<Row><Col className="label" span={10}>Duration</Col><Col className="duration" span={12}>
{ startedAt ? dayjs(updatedAt).from(dayjs(startedAt), true) : "" }
</Col></Row>
</div>
)
}
componentDidMount() {
dayjs.extend(relativeTime)
this.fetchRecords()
}
componentWillUnmount() {
window.clearInterval(this.state.pollID)
}
fetchRecords = (params) => {
let qParams = {
page: this.state.queryParams.page,
per_page: this.state.queryParams.per_page
}
// The records are for a specific list.
if(this.state.queryParams.listID) {
qParams.listID = this.state.queryParams.listID
}
if(params) {
qParams = { ...qParams, ...params }
}
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.GetCampaigns, cs.MethodGet, qParams).then((r) => {
this.startStatsPoll()
})
}
startStatsPoll = () => {
window.clearInterval(this.state.pollID)
this.setState({ "stats": {} })
// If there's at least one running campaign, start polling.
let hasRunning = false
this.props.data[cs.ModelCampaigns].forEach((c) => {
if(c.status === cs.CampaignStatusRunning) {
hasRunning = true
return
}
})
if(!hasRunning) {
return
}
// Poll for campaign stats.
let pollID = window.setInterval(() => {
this.props.request(cs.Routes.GetRunningCampaignStats, cs.MethodGet).then((r) => {
// No more running campaigns.
if(r.data.data.length === 0) {
window.clearInterval(this.state.pollID)
this.fetchRecords()
return
}
let stats = {}
r.data.data.forEach((s) => {
stats[s.id] = s
})
this.setState({ stats: stats })
}).catch(e => {
console.log(e.message)
})
}, 3000)
this.setState({ pollID: pollID })
}
handleUpdateStatus = (record, status) => {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.UpdateCampaignStatus, cs.MethodPut, { id: record.id, status: status })
.then(() => {
notification["success"]({ placement: "topRight", message: `Campaign ${status}`, description: `"${record.name}" ${status}` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleDeleteRecord = (record) => {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.DeleteCampaign, cs.MethodDelete, { id: record.id })
.then(() => {
notification["success"]({ placement: "topRight", message: "Campaign deleted", description: `"${record.name}" deleted` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleToggleCloneForm = (record) => {
this.setState({ record: record, cloneName: record.name })
}
handleCloneCampaign = (record) => {
this.setState({ modalWaiting: true })
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.CreateCampaign, cs.MethodPost, record).then((resp) => {
notification["success"]({ placement: "topRight",
message: "Campaign created",
description: `${record.name} created` })
this.setState({ record: null, modalWaiting: false })
this.props.route.history.push(cs.Routes.ViewCampaign.replace(":id", resp.data.data.id))
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
}
render() {
const pagination = {
...this.paginationOptions,
...this.state.queryParams
}
return (
<section className="content campaigns">
<Row>
<Col span={ 22 }><h1>Campaigns</h1></Col>
<Col span={ 2 }>
<Link to="/campaigns/new"><Button type="primary" icon="plus" role="link">New campaign</Button></Link>
</Col>
</Row>
<br />
<Table
className="subscribers"
columns={ this.columns }
rowKey={ record => record.uuid }
dataSource={ this.props.data[cs.ModelCampaigns] }
loading={ this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone }
pagination={ pagination }
/>
{ this.state.record &&
<Modal visible={ this.state.record } width="500px"
className="clone-campaign-modal"
title={ "Clone " + this.state.record.name}
okText="Clone"
confirmLoading={ this.state.modalWaiting }
onCancel={ this.handleToggleCloneForm }
onOk={() => { this.handleCloneCampaign({ ...this.state.record, name: this.state.cloneName }) }}>
<Input autoFocus defaultValue={ this.state.record.name } style={{ width: "100%" }} onChange={(e) => {
this.setState({ cloneName: e.target.value })
}} />
</Modal> }
</section>
)
}
}
export default Campaigns

View File

@ -0,0 +1,26 @@
.App {
text-align: center;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 80px;
}
.App-header {
height: 150px;
padding: 20px;
}
.App-title {
font-size: 1.5em;
}
.App-intro {
font-size: large;
}
@keyframes App-logo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import 'antd/dist/antd.css';
class Dashboard extends React.Component {
render() {
return (
<h1>Welcome</h1>
);
}
}
export default Dashboard;

View File

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

345
frontend/my/src/Import.js Normal file
View File

@ -0,0 +1,345 @@
import React from "react"
import { Row, Col, Form, Select, Input, Checkbox, Upload, Button, Icon, Spin, Progress, Popconfirm, Tag, notification } from "antd"
import * as cs from "./constants"
const StatusNone = "none"
const StatusImporting = "importing"
const StatusStopping = "stopping"
const StatusFinished = "finished"
const StatusFailed = "failed"
class TheFormDef extends React.PureComponent {
state = {
confirmDirty: false,
fileList: []
}
componentDidMount() {
// Fetch lists.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
}
// Handle create / edit form submission.
handleSubmit = (e) => {
e.preventDefault()
var err = null, values = {}
this.props.form.validateFields((e, v) => {
err = e
values = v
})
if (err) {
return
}
if(this.state.fileList.length < 1) {
notification["error"]({ message: "Error", description: "Select a valid file to upload" })
return
}
let params = new FormData()
params.set("params", JSON.stringify(values))
params.append("file", this.state.fileList[0])
this.props.request(cs.Routes.UploadRouteImport, cs.MethodPost, params).then(() => {
notification["info"]({ message: "File uploaded",
description: "Please wait while the import is running" })
this.props.fetchimportState()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleConfirmBlur = (e) => {
const value = e.target.value
this.setState({ confirmDirty: this.state.confirmDirty || !!value })
}
onFileChange = (f) => {
let fileList = [f]
this.setState({ fileList })
return false
}
render() {
const { getFieldDecorator } = this.props.form
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
}
const formItemTailLayout = {
wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
}
return (
<Spin spinning={false}>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to">
{getFieldDecorator("lists", { rules: [{ required: true }] })(
<Select mode="multiple">
{[...this.props.lists].map((v, i) =>
<Select.Option value={v["id"]} key={v["id"]}>{v["name"]}</Select.Option>
)}
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout}
label="Override status?"
extra="For existing subscribers in the system found again in the import, override their status, for example 'blacklisted' to 'active'. This is not always desirable.">
{getFieldDecorator("override_status", )(
<Checkbox initialValue="1" />
)}
</Form.Item>
<Form.Item {...formItemLayout} label="CSV column delimiter" extra="Default delimiter is comma">
{getFieldDecorator("delim", {
initialValue: ","
})(<Input maxLength="1" style={{ maxWidth: 40 }} />)}
</Form.Item>
<Form.Item
{...formItemLayout}
label="ZIP file">
<div className="dropbox">
{getFieldDecorator("file", {
valuePropName: "file",
getValueFromEvent: this.normFile,
rules: [{ required: true }]
})(
<Upload.Dragger name="files"
multiple={ false }
fileList={ this.state.fileList }
beforeUpload={ this.onFileChange }
accept=".zip">
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">Click or drag file here</p>
</Upload.Dragger>
)}
</div>
</Form.Item>
<Form.Item {...formItemTailLayout}>
<Button type="primary" htmlType="submit"><Icon type="upload" /> Upload &amp; import</Button>
</Form.Item>
</Form>
</Spin>
)
}
}
const TheForm = Form.create()(TheFormDef)
class Importing extends React.PureComponent {
state = {
pollID: -1,
logs: ""
}
stopImport = () => {
// Get the import status.
this.props.request(cs.Routes.UploadRouteImport, cs.MethodDelete).then((r) => {
this.props.fetchimportState()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
componentDidMount() {
// Poll for stats until it's finished or failed.
let pollID = window.setInterval(() => {
this.props.fetchimportState()
this.fetchLogs()
if( this.props.importState.status === StatusFinished ||
this.props.importState.status === StatusFailed ) {
window.clearInterval(this.state.pollID)
}
}, 1000)
this.setState({ pollID: pollID })
}
componentWillUnmount() {
window.clearInterval(this.state.pollID)
}
fetchLogs() {
this.props.request(cs.Routes.GetRouteImportLogs, cs.MethodGet).then((r) => {
this.setState({ logs: r.data.data })
let t = document.querySelector("#log-textarea")
t.scrollTop = t.scrollHeight;
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
render() {
let progressPercent = 0
if( this.props.importState.status === StatusFinished ) {
progressPercent = 100
} else {
progressPercent = Math.floor(this.props.importState.imported / this.props.importState.total * 100)
}
return(
<section className="content import">
<h1>Importing &mdash; { this.props.importState.name }</h1>
{ this.props.importState.status === StatusImporting &&
<p>Import is in progress. It is safe to navigate away from this page.</p>
}
{ this.props.importState.status !== StatusImporting &&
<p>Import has finished.</p>
}
<Row className="import-container">
<Col span="10" offset="3">
<div className="stats center">
<div>
<Progress type="line" percent={ progressPercent } />
</div>
<div>
<h3>{ this.props.importState.imported } records</h3>
<br />
{ this.props.importState.status === StatusImporting &&
<Popconfirm title="Are you sure?" onConfirm={() => this.stopImport()}>
<p><Icon type="loading" /></p>
<Button type="primary">Stop import</Button>
</Popconfirm>
}
{ this.props.importState.status === StatusStopping &&
<div>
<p><Icon type="loading" /></p>
<h4>Stopping</h4>
</div>
}
{ this.props.importState.status !== StatusImporting &&
this.props.importState.status !== StatusStopping &&
<div>
{ this.props.importState.status !== StatusFinished &&
<div>
<Tag color="red">{ this.props.importState.status }</Tag>
<br />
</div>
}
<br />
<Button type="primary" onClick={() => this.stopImport()}>Done</Button>
</div>
}
</div>
</div>
<div className="logs">
<h3>Import log</h3>
<Spin spinning={ this.state.logs === "" }>
<Input.TextArea placeholder="Import logs"
id="log-textarea"
rows={10}
value={ this.state.logs }
autosize={{ minRows: 2, maxRows: 10 }} />
</Spin>
</div>
</Col>
</Row>
</section>
)
}
}
class Import extends React.PureComponent {
state = {
importState: { "status": "" }
}
fetchimportState = () => {
// Get the import status.
this.props.request(cs.Routes.GetRouteImportStats, cs.MethodGet).then((r) => {
this.setState({ importState: r.data.data })
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
componentDidMount() {
this.fetchimportState()
}
render() {
if( this.state.importState.status === "" ) {
// Fetching the status.
return (
<section className="content center">
<Spin />
</section>
)
} else if ( this.state.importState.status !== StatusNone ) {
// There's an import state
return <Importing { ...this.props }
importState={ this.state.importState }
fetchimportState={ this.fetchimportState } />
}
return (
<section className="content import">
<Row>
<Col span={22}><h1>Import subscribers</h1></Col>
<Col span={2}>
</Col>
</Row>
<TheForm { ...this.props }
fetchimportState={ this.fetchimportState }
lists={ this.props.data[cs.ModelLists] }>
</TheForm>
<hr />
<div className="help">
<h2>Instructions</h2>
<p>Upload a ZIP file with a single CSV file in it
to bulk import a large number of subscribers in a single shot.
</p>
<p>
The CSV file should have the following headers with the exact column names
(<code>status</code> and <code>attributes</code> are optional).
{" "}
<code>attributes</code> should be a valid JSON string with double escaped quotes.
Spreadsheet programs should automatically take care of this without having to manually
escape quotes.
</p>
<blockquote className="csv-example">
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>status,</span>
<span>attributes</span>
</code>
</blockquote>
<h3>Example raw CSV</h3>
<blockquote className="csv-example">
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>status,</span>
<span>attributes</span>
</code>
<code className="csv-row">
<span>user1@mail.com,</span>
<span>"User One",</span>
<span>enabled,</span>
<span>{ '"{""age"": 32, ""city"": ""Bangalore""}"' }</span>
</code>
<code className="csv-row">
<span>user2@mail.com,</span>
<span>"User Two",</span>
<span>blacklisted,</span>
<span>{ '"{""age"": 25, ""occupation"": ""Time Traveller""}"' }</span>
</code>
</blockquote>
</div>
</section>
)
}
}
export default Import

112
frontend/my/src/Layout.js Normal file
View File

@ -0,0 +1,112 @@
import React from "react"
import { Switch, Route } from "react-router-dom"
import { Link } from "react-router-dom"
import { Layout, Menu, Icon } from "antd"
import "antd/dist/antd.css"
import logo from "./static/listmonk.svg"
// Views.
import Dashboard from "./Dashboard"
import Lists from "./Lists"
import Subscribers from "./Subscribers"
import Templates from "./Templates"
import Import from "./Import"
import Test from "./Test"
import Campaigns from "./Campaigns";
import Campaign from "./Campaign";
import Media from "./Media";
const { Content, Footer, Sider } = Layout
const SubMenu = Menu.SubMenu
class Base extends React.Component {
state = {
basePath: "/" + window.location.pathname.split("/")[1],
error: null,
collapsed: false
};
onCollapse = (collapsed) => {
this.setState({ collapsed })
}
render() {
return (
<Layout style={{ minHeight: "100vh" }}>
<Sider
collapsible
collapsed={this.state.collapsed}
onCollapse={this.onCollapse}
theme="light"
>
<div className="logo">
<Link to="/"><img src={logo} alt="listmonk logo" /></Link>
</div>
<Menu defaultSelectedKeys={["/"]}
selectedKeys={[window.location.pathname]}
defaultOpenKeys={[this.state.basePath]}
mode="inline">
<Menu.Item key="/"><Link to="/"><Icon type="dashboard" /><span>Dashboard</span></Link></Menu.Item>
<Menu.Item key="/lists"><Link to="/lists"><Icon type="bars" /><span>Lists</span></Link></Menu.Item>
<SubMenu
key="/subscribers"
title={<span><Icon type="team" /><span>Subscribers</span></span>}>
<Menu.Item key="/subscribers"><Link to="/subscribers"><Icon type="team" /> All subscribers</Link></Menu.Item>
<Menu.Item key="/subscribers/import"><Link to="/subscribers/import"><Icon type="upload" /> Import</Link></Menu.Item>
</SubMenu>
<SubMenu
key="/campaigns"
title={<span><Icon type="rocket" /><span>Campaigns</span></span>}>
<Menu.Item key="/campaigns"><Link to="/campaigns"><Icon type="rocket" /> All campaigns</Link></Menu.Item>
<Menu.Item key="/campaigns/new"><Link to="/campaigns/new"><Icon type="plus" /> Create new</Link></Menu.Item>
<Menu.Item key="/campaigns/media"><Link to="/campaigns/media"><Icon type="picture" /> Media</Link></Menu.Item>
<Menu.Item key="/campaigns/templates"><Link to="/campaigns/templates"><Icon type="code-o" /> Templates</Link></Menu.Item>
</SubMenu>
<SubMenu
key="/settings"
title={<span><Icon type="setting" /><span>Settings</span></span>}>
<Menu.Item key="9"><Icon type="user" /> Users</Menu.Item>
<Menu.Item key="10"><Icon type="setting" />Settings</Menu.Item>
</SubMenu>
<Menu.Item key="11"><Icon type="logout" /><span>Logout</span></Menu.Item>
</Menu>
</Sider>
<Layout>
<Content style={{ margin: "0 16px" }}>
<div className="content-body">
<div id="alert-container"></div>
{/* <this.props.body setError={this.setError} /> */}
<Switch>
<Route exact path="/" render={(props) => <Dashboard { ...{ ...this.props, route: props } } />} />
<Route exact path="/lists" render={(props) => <Lists { ...{ ...this.props, route: props } } />} />
<Route exact path="/subscribers" render={(props) => <Subscribers { ...{ ...this.props, route: props } } />} />
<Route exact path="/subscribers/lists/:listID" render={(props) => <Subscribers { ...{ ...this.props, route: props } } />} />
<Route exact path="/subscribers/import" render={(props) => <Import { ...{ ...this.props, route: props } } />} />
<Route exact path="/campaigns" render={(props) => <Campaigns { ...{ ...this.props, route: props } } />} />
<Route exact path="/campaigns/new" render={(props) => <Campaign { ...{ ...this.props, route: props } } />} />
<Route exact path="/campaigns/media" render={(props) => <Media { ...{ ...this.props, route: props } } />} />
<Route exact path="/campaigns/templates" render={(props) => <Templates { ...{ ...this.props, route: props } } />} />
<Route exact path="/campaigns/:campaignID" render={(props) => <Campaign { ...{ ...this.props, route: props } } />} />
<Route exact path="/test" render={(props) => <Test { ...{ ...this.props, route: props } } />} />
</Switch>
</div>
</Content>
<Footer style={{ textAlign: "center" }}>
listmonk &copy; 2018. MIT License.
</Footer>
</Layout>
</Layout>
)
}
}
export default Base

240
frontend/my/src/Lists.js Normal file
View File

@ -0,0 +1,240 @@
import React from "react"
import { Link } from "react-router-dom"
import { Row, Col, Modal, Form, Input, Select, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd"
import Utils from "./utils"
import * as cs from "./constants"
class CreateFormDef extends React.PureComponent {
state = {
confirmDirty: false,
modalWaiting: false
}
// Handle create / edit form submission.
handleSubmit = (e) => {
e.preventDefault()
this.props.form.validateFields((err, values) => {
if (err) {
return
}
this.setState({ modalWaiting: true })
if (this.props.formType === cs.FormCreate) {
// Create a new list.
this.props.modelRequest(cs.ModelLists, cs.Routes.CreateList, cs.MethodPost, values).then(() => {
notification["success"]({ placement: "topRight", message: "List created", description: `"${values["name"]}" created` })
this.props.fetchRecords()
this.props.onClose()
this.setState({ modalWaiting: false })
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
} else {
// Edit a list.
this.props.modelRequest(cs.ModelLists, cs.Routes.UpdateList, cs.MethodPut, { ...values, id: this.props.record.id }).then(() => {
notification["success"]({ placement: "topRight", message: "List modified", description: `"${values["name"]}" modified` })
this.props.fetchRecords()
this.props.onClose()
this.setState({ modalWaiting: false })
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
}
})
}
render() {
const { formType, record, onClose } = this.props
const { getFieldDecorator } = this.props.form
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
}
if (formType === null) {
return null
}
return (
<Modal visible={ true } title={ formType === cs.FormCreate ? "Create a list" : record.name }
okText={ this.state.form === cs.FormCreate ? "Create" : "Save" }
confirmLoading={ this.state.modalWaiting }
onCancel={ onClose }
onOk={ this.handleSubmit }>
<div id="modal-alert-container"></div>
<Spin spinning={ this.props.reqStates[cs.ModelLists] === cs.StatePending }>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input autoFocus maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} name="type" label="Type" extra="Public lists are open to the world to subscribe">
{getFieldDecorator("type", { initialValue: record.type ? record.type : "private", rules: [{ required: true }] })(
<Select style={{ maxWidth: 120 }}>
<Select.Option value="private">Private</Select.Option>
<Select.Option value="public">Public</Select.Option>
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags">
{getFieldDecorator("tags", { initialValue: record.tags })(
<Select mode="tags"></Select>
)}
</Form.Item>
</Form>
</Spin>
</Modal>
)
}
}
const CreateForm = Form.create()(CreateFormDef)
class Lists extends React.PureComponent {
state = {
formType: null,
record: {}
}
constructor(props) {
super(props)
this.columns = [{
title: "Name",
dataIndex: "name",
sorter: true,
width: "50%",
render: (text, record) => {
const out = [];
out.push(
<div className="name" key={`name-${record.id}`}><Link to={ `/subscribers/lists/${record.id}` }>{ text }</Link></div>
)
if(record.tags.length > 0) {
for (let i = 0; i < record.tags.length; i++) {
out.push(<Tag key={`tag-${i}`}>{ record.tags[i] }</Tag>);
}
}
return out
}
},
{
title: "Type",
dataIndex: "type",
width: "5%",
render: (type, _) => {
let color = type === "private" ? "orange" : "green"
return <Tag color={color}>{type}</Tag>
}
},
{
title: "Subscribers",
dataIndex: "subscriber_count",
width: "10%",
align: "center"
},
{
title: "Created",
dataIndex: "created_at",
render: (date, _) => {
return Utils.DateString(date)
}
},
{
title: "Updated",
dataIndex: "updated_at",
render: (date, _) => {
return Utils.DateString(date)
}
},
{
title: "",
dataIndex: "actions",
width: "10%",
render: (text, record) => {
return (
<div className="actions">
<Tooltip title="Send a campaign"><a role="button"><Icon type="rocket" /></a></Tooltip>
<Tooltip title="Edit list"><a role="button" onClick={() => this.handleShowEditForm(record)}><Icon type="edit" /></a></Tooltip>
<Popconfirm title="Are you sure?" onConfirm={() => this.deleteRecord(record)}>
<Tooltip title="Delete list" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip>
</Popconfirm>
</div>
)
}
}]
}
componentDidMount() {
this.fetchRecords()
}
fetchRecords = () => {
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
}
deleteRecord = (record) => {
this.props.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, { id: record.id })
.then(() => {
notification["success"]({ placement: "topRight", message: "List deleted", description: `"${record.name}" deleted` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleHideForm = () => {
this.setState({ formType: null })
}
handleShowCreateForm = () => {
this.setState({ formType: cs.FormCreate, record: {} })
}
handleShowEditForm = (record) => {
this.setState({ formType: cs.FormEdit, record: record })
}
render() {
return (
<section className="content">
<Row>
<Col span={22}><h1>Lists ({this.props.data[cs.ModelLists].length}) </h1></Col>
<Col span={2}>
<Button type="primary" icon="plus" onClick={this.handleShowCreateForm}>Create list</Button>
</Col>
</Row>
<br />
<Table
className="lists"
columns={ this.columns }
rowKey={ record => record.uuid }
dataSource={ this.props.data[cs.ModelLists] }
loading={ this.props.reqStates[cs.ModelLists] !== cs.StateDone }
pagination={ false }
/>
<CreateForm { ...this.props }
formType={ this.state.formType }
record={ this.state.record }
onClose={ this.handleHideForm }
fetchRecords = { this.fetchRecords }
/>
</section>
)
}
}
export default Lists

129
frontend/my/src/Media.js Normal file
View File

@ -0,0 +1,129 @@
import React from "react"
import { Row, Col, Form, Upload, Icon, Spin, Popconfirm, Tooltip, notification } from "antd"
import * as cs from "./constants"
class TheFormDef extends React.PureComponent {
state = {
confirmDirty: false
}
componentDidMount() {
this.fetchRecords()
}
fetchRecords = () => {
this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet)
}
handleDeleteRecord = (record) => {
this.props.modelRequest(cs.ModelMedia, cs.Routes.DeleteMedia, cs.MethodDelete, { id: record.id })
.then(() => {
notification["success"]({ placement: "topRight", message: "Image deleted", description: `"${record.filename}" deleted` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleInsertMedia = (record) => {
// The insertMedia callback may be passed down by the invoker (Campaign)
if(!this.props.insertMedia) {
return false
}
this.props.insertMedia(record.uri)
return false
}
onFileChange = (f) => {
if(f.file.error && f.file.response && f.file.response.hasOwnProperty("message")) {
notification["error"]({ message: "Error uploading file", description: f.file.response.message })
} else if(f.file.status === "done") {
this.fetchRecords()
}
return false
}
render() {
const { getFieldDecorator } = this.props.form
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
}
return (
<Spin spinning={false}>
<Form>
<Form.Item
{...formItemLayout}
label="Upload images">
<div className="dropbox">
{getFieldDecorator("file", {
valuePropName: "file",
getValueFromEvent: this.normFile,
rules: [{ required: true }]
})(
<Upload.Dragger
name="file"
action="/api/media"
multiple={ true }
listType="picture"
onChange={ this.onFileChange }
accept=".gif, .jpg, .jpeg, .png">
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">Click or drag file here</p>
</Upload.Dragger>
)}
</div>
</Form.Item>
</Form>
<section className="gallery">
{this.props.media && this.props.media.map((record, i) =>
<div key={ i } className="image">
<a onClick={ () => {
this.handleInsertMedia(record);
if( this.props.onCancel ) {
this.props.onCancel();
}
} }><img alt={ record.filename } src={ record.thumb_uri } /></a>
<div className="actions">
<Tooltip title="View" placement="bottom"><a role="button" href={ record.uri } target="_blank"><Icon type="login" /></a></Tooltip>
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}>
<Tooltip title="Delete" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip>
</Popconfirm>
</div>
<div className="name" title={ record.filename }>{ record.filename }</div>
</div>
)}
</section>
</Spin>
)
}
}
const TheForm = Form.create()(TheFormDef)
class Media extends React.PureComponent {
render() {
return (
<section className="content media">
<Row>
<Col span={22}><h1>Images</h1></Col>
<Col span={2}>
</Col>
</Row>
<TheForm { ...this.props }
media={ this.props.data[cs.ModelMedia] }>
</TheForm>
</section>
)
}
}
export default Media

View File

@ -0,0 +1,517 @@
import React from "react"
import { Row, Col, Modal, Form, Input, Select, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd"
import Utils from "./utils"
import * as cs from "./constants"
const tagColors = {
"enabled": "green",
"blacklisted": "red"
}
class CreateFormDef extends React.PureComponent {
state = {
confirmDirty: false,
attribs: {},
modalWaiting: false
}
componentDidMount() {
this.setState({ attribs: this.props.record.attribs })
}
// Handle create / edit form submission.
handleSubmit = (e) => {
e.preventDefault()
var err = null, values = {}
this.props.form.validateFields((e, v) => {
err = e
values = v
})
if(err) {
return
}
values["attribs"] = {}
let a = this.props.form.getFieldValue("attribs-json")
if(a && a.length > 0) {
try {
values["attribs"] = JSON.parse(a)
if(values["attribs"] instanceof Array) {
notification["error"]({ message: "Invalid JSON type",
description: "Attributes should be a map {} and not an array []" })
return
}
} catch(e) {
notification["error"]({ message: "Invalid JSON in attributes", description: e.toString() })
return
}
}
this.setState({ modalWaiting: true })
if (this.props.formType === cs.FormCreate) {
// Add a subscriber.
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.CreateSubscriber, cs.MethodPost, values).then(() => {
notification["success"]({ message: "Subscriber added", description: `${values["email"]} added` })
this.props.fetchRecords()
this.props.onClose()
this.setState({ modalWaiting: false })
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
} else {
// Edit a subscriber.
delete(values["keys"])
delete(values["vals"])
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.UpdateSubscriber, cs.MethodPut, { ...values, id: this.props.record.id }).then(() => {
notification["success"]({ message: "Subscriber modified", description: `${values["email"]} modified` })
// Reload the table.
this.props.fetchRecords()
this.props.onClose()
this.setState({ modalWaiting: false })
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
}
}
modalTitle(formType, record) {
if(formType === cs.FormCreate) {
return "Add subscriber"
}
return (
<span>
<Tag color={ tagColors.hasOwnProperty(record.status) ? tagColors[record.status] : "" }>{ record.status }</Tag>
{" "}
{ record.name } ({ record.email })
</span>
)
}
render() {
const { formType, record, onClose } = this.props;
const { getFieldDecorator } = this.props.form
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
}
if (formType === null) {
return null
}
let subListIDs = []
let subStatuses = {}
if(this.props.record && this.props.record.lists) {
subListIDs = this.props.record.lists.map((v) => { return v["id"] })
subStatuses = this.props.record.lists.reduce((o, item) => ({ ...o, [item.id]: item.subscription_status}), {})
} else if(this.props.list) {
subListIDs = [ this.props.list.id ]
}
return (
<Modal visible={ true } width="750px"
className="subscriber-modal"
title={ this.modalTitle(formType, record) }
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
confirmLoading={ this.state.modalWaiting }
onCancel={ onClose }
onOk={ this.handleSubmit }
okButtonProps={{ disabled: this.props.reqStates[cs.ModelSubscribers] === cs.StatePending }}>
<div id="modal-alert-container"></div>
<Spin spinning={ this.props.reqStates[cs.ModelSubscribers] === cs.StatePending }>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="E-mail">
{getFieldDecorator("email", {
initialValue: record.email,
rules: [{ required: true }]
})(<Input autoFocus pattern="(.+?)@(.+?)" maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} name="status" label="Status" extra="Blacklisted users will not receive any e-mails ever">
{getFieldDecorator("status", { initialValue: record.status ? record.status : "enabled", rules: [{ required: true, message: "Type is required" }] })(
<Select style={{ maxWidth: 120 }}>
<Select.Option value="enabled">Enabled</Select.Option>
<Select.Option value="blacklisted">Blacklisted</Select.Option>
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to. Lists from which subscribers have unsubscribed themselves cannot be removed.">
{getFieldDecorator("lists", { initialValue: subListIDs })(
<Select mode="multiple">
{[...this.props.lists].map((v, i) =>
<Select.Option value={ v.id } key={ v.id } disabled={ subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed }>
<span>{ v.name }
{ subStatuses[v.id] &&
<sup className={ "status " + subStatuses[v.id] }> { subStatuses[v.id] }</sup>
}
</span>
</Select.Option>
)}
</Select>
)}
</Form.Item>
<section>
<h3>Attributes</h3>
<p className="ant-form-extra">Attributes can be defined as a JSON map, for example:
{'{"age": 30, "color": "red", "is_user": true}'}. <a href="">More info</a>.</p>
<div className="json-editor">
{getFieldDecorator("attribs-json", {
initialValue: JSON.stringify(this.state.attribs, null, 4)
})(
<Input.TextArea placeholder="{}"
rows={10}
readOnly={false}
autosize={{ minRows: 5, maxRows: 10 }} />)}
</div>
</section>
</Form>
</Spin>
</Modal>
)
}
}
const CreateForm = Form.create()(CreateFormDef)
class Subscribers extends React.PureComponent {
defaultPerPage = 20
state = {
formType: null,
record: {},
queryParams: {
page: 1,
total: 0,
perPage: this.defaultPerPage,
listID: this.props.route.match.params.listID ? parseInt(this.props.route.match.params.listID, 10) : 0,
list: null,
query: null,
targetLists: []
},
listAddVisible: false
}
// Pagination config.
paginationOptions = {
hideOnSinglePage: true,
showSizeChanger: true,
showQuickJumper: true,
defaultPageSize: this.defaultPerPage,
pageSizeOptions: ["20", "50", "70", "100"],
position: "both",
showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
onChange: (page, perPage) => {
this.fetchRecords({ page: page, per_page: perPage })
},
onShowSizeChange: (page, perPage) => {
this.fetchRecords({ page: page, per_page: perPage })
}
}
constructor(props) {
super(props)
// Table layout.
this.columns = [{
title: "E-mail",
dataIndex: "email",
sorter: true,
width: "25%",
render: (text, record) => {
return (
<a role="button" onClick={() => this.handleShowEditForm(record)}>{text}</a>
)
}
},
{
title: "Name",
dataIndex: "name",
sorter: true,
width: "25%",
render: (text, record) => {
return (
<a role="button" onClick={() => this.handleShowEditForm(record)}>{text}</a>
)
}
},
{
title: "Status",
dataIndex: "status",
width: "5%",
render: (status, _) => {
return <Tag color={ tagColors.hasOwnProperty(status) ? tagColors[status] : "" }>{ status }</Tag>
}
},
{
title: "Lists",
dataIndex: "lists",
width: "10%",
align: "center",
render: (lists, _) => {
return <span>{ lists.reduce((def, item) => def + (item.subscription_status !== cs.SubscriptionStatusUnsubscribed ? 1 : 0), 0) }</span>
}
},
{
title: "Created",
width: "10%",
dataIndex: "created_at",
render: (date, _) => {
return Utils.DateString(date)
}
},
{
title: "Updated",
width: "10%",
dataIndex: "updated_at",
render: (date, _) => {
return Utils.DateString(date)
}
},
{
title: "",
dataIndex: "actions",
width: "10%",
render: (text, record) => {
return (
<div className="actions">
{/* <Tooltip title="Send an e-mail"><a role="button"><Icon type="rocket" /></a></Tooltip> */}
<Tooltip title="Edit subscriber"><a role="button" onClick={() => this.handleShowEditForm(record)}><Icon type="edit" /></a></Tooltip>
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}>
<Tooltip title="Delete subscriber" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip>
</Popconfirm>
</div>
)
}
}
]
}
componentDidMount() {
// Load lists on boot.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet).then(() => {
// If this is an individual list's view, pick up that list.
if(this.state.queryParams.listID) {
this.props.data[cs.ModelLists].forEach((l) => {
if(l.id === this.state.queryParams.listID) {
this.setState({ queryParams: { ...this.state.queryParams, list: l }})
return false
}
})
}
})
this.fetchRecords()
}
fetchRecords = (params) => {
let qParams = {
page: this.state.queryParams.page,
per_page: this.state.queryParams.per_page,
list_id: this.state.queryParams.listID,
query: this.state.queryParams.query
}
// The records are for a specific list.
if(this.state.queryParams.listID) {
qParams.list_id = this.state.queryParams.listID
}
if(params) {
qParams = { ...qParams, ...params }
}
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.GetSubscribers, cs.MethodGet, qParams).then(() => {
this.setState({ queryParams: {
...this.state.queryParams,
total: this.props.data[cs.ModelSubscribers].total,
perPage: this.props.data[cs.ModelSubscribers].per_page,
page: this.props.data[cs.ModelSubscribers].page,
query: this.props.data[cs.ModelSubscribers].query,
}})
})
}
handleDeleteRecord = (record) => {
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.DeleteSubscriber, cs.MethodDelete, { id: record.id })
.then(() => {
notification["success"]({ message: "Subscriber deleted", description: `${record.email} deleted` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleQuerySubscribersIntoLists = (query, sourceList, targetLists) => {
let params = {
query: query,
source_list: sourceList,
target_lists: targetLists
}
this.props.request(cs.Routes.QuerySubscribersIntoLists, cs.MethodPost, params).then((res) => {
notification["success"]({ message: "Subscriber(s) added", description: `${ res.data.data.count } added` })
this.handleToggleListAdd()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleHideForm = () => {
this.setState({ formType: null })
}
handleShowCreateForm = () => {
this.setState({ formType: cs.FormCreate, attribs: [], record: {} })
}
handleShowEditForm = (record) => {
this.setState({ formType: cs.FormEdit, record: record })
}
handleToggleQueryForm = () => {
// The query form is being cancelled. Reset the results.
if(this.state.queryFormVisible) {
this.fetchRecords({
query: null
})
}
this.setState({ queryFormVisible: !this.state.queryFormVisible })
}
handleToggleListAdd = () => {
this.setState({ listAddVisible: !this.state.listAddVisible })
}
render() {
const pagination = {
...this.paginationOptions,
...this.state.queryParams
}
return (
<section className="content">
<header className="header">
<Row>
<Col span={ 20 }>
<h1>
Subscribers
{ this.state.queryParams.list &&
<span> &raquo; { this.state.queryParams.list.name }</span> }
</h1>
</Col>
<Col span={ 2 }>
{ !this.state.queryFormVisible &&
<a role="button" onClick={ this.handleToggleQueryForm }><Icon type="search" /> Advanced</a> }
</Col>
<Col span={ 2 }>
<Button type="primary" icon="plus" onClick={ this.handleShowCreateForm }>Add subscriber</Button>
</Col>
</Row>
</header>
{ this.state.queryFormVisible &&
<div className="subscriber-query">
<p>
Write a partial SQL expression to query the subscribers based on their
primary information or attributes. Learn more.
</p>
<Input.TextArea placeholder="name LIKE '%user%'"
id="subscriber-query"
rows={ 10 }
onChange={(e) => {
this.setState({ queryParams: { ...this.state.queryParams, query: e.target.value } })
}}
autosize={{ minRows: 2, maxRows: 10 }} />
<div className="actions">
<Button
disabled={ this.state.queryParams.query === "" }
type="primary"
icon="search"
onClick={ () => { this.fetchRecords() } }>Query</Button>
{" "}
<Button
disabled={ !this.state.queryParams.total }
icon="plus"
onClick={ this.handleToggleListAdd }>Add ({this.state.queryParams.total}) to list</Button>
{" "}
<Button icon="close" onClick={ this.handleToggleQueryForm }>Cancel</Button>
</div>
</div>
}
<Table
columns={ this.columns }
rowKey={ record => `${record.id}-${record.email}` }
dataSource={ this.props.data[cs.ModelSubscribers].results }
loading={ this.props.reqStates[cs.ModelSubscribers] !== cs.StateDone }
pagination={ pagination }
rowSelection = {{
fixed: true
}}
/>
{ this.state.formType !== null && <CreateForm {...this.props}
formType={ this.state.formType }
record={ this.state.record }
lists={ this.props.data[cs.ModelLists] }
list={ this.state.queryParams.list }
fetchRecords={ this.fetchRecords }
queryParams= { this.state.queryParams }
onClose={ this.handleHideForm } />
}
<Modal visible={ this.state.listAddVisible } width="750px"
className="list-add-modal"
title={ "Add " + this.props.data[cs.ModelSubscribers].total + " subscriber(s) to lists" }
okText="Add"
onCancel={ this.handleToggleListAdd }
onOk={() => {
if(this.state.queryParams.targetLists.length == 0) {
notification["warning"]({
message: "No lists selected",
description: "Select one or more lists"
})
return false
}
this.handleQuerySubscribersIntoLists(
this.state.queryParams.query,
this.state.queryParams.listID,
this.state.queryParams.targetLists
)
}}
okButtonProps={{ disabled: this.props.reqStates[cs.ModelSubscribers] === cs.StatePending }}>
<Select mode="multiple" style={{ width: "100%" }} onChange={(lists) => {
this.setState({ queryParams: { ...this.state.queryParams, targetLists: lists} })
}}>
{ this.props.data[cs.ModelLists].map((v, i) =>
<Select.Option value={ v.id } key={ v.id }>{ v.name }</Select.Option>
)}
</Select>
</Modal>
</section>
)
}
}
export default Subscribers

View File

@ -0,0 +1,262 @@
import React from "react"
import { Row, Col, Modal, Form, Input, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd"
import Utils from "./utils"
import * as cs from "./constants"
class CreateFormDef extends React.PureComponent {
state = {
confirmDirty: false,
modalWaiting: false
}
// Handle create / edit form submission.
handleSubmit = (e) => {
e.preventDefault()
this.props.form.validateFields((err, values) => {
if (err) {
return
}
this.setState({ modalWaiting: true })
if (this.props.formType === cs.FormCreate) {
// Create a new list.
this.props.modelRequest(cs.ModelTemplates, cs.Routes.CreateTemplate, cs.MethodPost, values).then(() => {
notification["success"]({ placement: "topRight", message: "Template added", description: `"${values["name"]}" added` })
this.props.fetchRecords()
this.props.onClose()
this.setState({ modalWaiting: false })
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
} else {
// Edit a list.
this.props.modelRequest(cs.ModelTemplates, cs.Routes.UpdateTemplate, cs.MethodPut, { ...values, id: this.props.record.id }).then(() => {
notification["success"]({ placement: "topRight", message: "Template updated", description: `"${values["name"]}" modified` })
this.props.fetchRecords()
this.props.onClose()
this.setState({ modalWaiting: false })
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
}
})
}
handleConfirmBlur = (e) => {
const value = e.target.value
this.setState({ confirmDirty: this.state.confirmDirty || !!value })
}
render() {
const { formType, record, onClose } = this.props
const { getFieldDecorator } = this.props.form
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
}
if (formType === null) {
return null
}
return (
<Modal visible={ true } title={ formType === cs.FormCreate ? "Add template" : record.name }
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
width="90%"
height={ 900 }
confirmLoading={ this.state.modalWaiting }
onCancel={ onClose }
onOk={ this.handleSubmit }>
<Spin spinning={ this.props.reqStates[cs.ModelTemplates] === cs.StatePending }>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input autoFocus maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} name="body" label="Raw HTML">
{getFieldDecorator("body", { initialValue: record.body ? record.body : "", rules: [{ required: true }] })(
<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }}>
</Input.TextArea>
)}
</Form.Item>
</Form>
</Spin>
<Row>
<Col span="4"></Col>
<Col span="18" className="text-grey text-small">
The placeholder <code>{'{'}{'{'} template "content" . {'}'}{'}'}</code> should appear in the template. <a href="" target="_blank">Read more on templating</a>.
</Col>
</Row>
</Modal>
)
}
}
const CreateForm = Form.create()(CreateFormDef)
class Templates extends React.PureComponent {
state = {
formType: null,
record: {},
previewRecord: null
}
constructor(props) {
super(props)
this.columns = [{
title: "Name",
dataIndex: "name",
sorter: true,
width: "50%",
render: (text, record) => {
return (
<div className="name">
<a role="button" onClick={() => this.handleShowEditForm(record)}>{ text }</a>
{ record.is_default &&
<div><Tag>Default</Tag></div>}
</div>
)
}
},
{
title: "Created",
dataIndex: "created_at",
render: (date, _) => {
return Utils.DateString(date)
}
},
{
title: "Updated",
dataIndex: "updated_at",
render: (date, _) => {
return Utils.DateString(date)
}
},
{
title: "",
dataIndex: "actions",
width: "20%",
className: "actions",
render: (text, record) => {
return (
<div className="actions">
<Tooltip title="Preview template" onClick={() => this.handlePreview(record)}><a role="button"><Icon type="search" /></a></Tooltip>
{ !record.is_default &&
<Popconfirm title="Are you sure?" onConfirm={() => this.handleSetDefault(record)}>
<Tooltip title="Set as default" placement="bottom"><a role="button"><Icon type="check" /></a></Tooltip>
</Popconfirm>
}
<Tooltip title="Edit template"><a role="button" onClick={() => this.handleShowEditForm(record)}><Icon type="edit" /></a></Tooltip>
{ record.id !== 1 &&
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}>
<Tooltip title="Delete template" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip>
</Popconfirm>
}
</div>
)
}
}]
}
componentDidMount() {
this.fetchRecords()
}
fetchRecords = () => {
this.props.modelRequest(cs.ModelTemplates, cs.Routes.GetTemplates, cs.MethodGet)
}
handleDeleteRecord = (record) => {
this.props.modelRequest(cs.ModelTemplates, cs.Routes.DeleteTemplate, cs.MethodDelete, { id: record.id })
.then(() => {
notification["success"]({ placement: "topRight", message: "Template deleted", description: `"${record.name}" deleted` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handleSetDefault = (record) => {
this.props.modelRequest(cs.ModelTemplates, cs.Routes.SetDefaultTemplate, cs.MethodPut, { id: record.id })
.then(() => {
notification["success"]({ placement: "topRight", message: "Template updated", description: `"${record.name}" set as default` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
handlePreview = (record) => {
this.setState({ previewRecord: record })
}
hideForm = () => {
this.setState({ formType: null })
}
handleShowCreateForm = () => {
this.setState({ formType: cs.FormCreate, record: {} })
}
handleShowEditForm = (record) => {
this.setState({ formType: cs.FormEdit, record: record })
}
render() {
return (
<section className="content templates">
<Row>
<Col span={22}><h1>Templates ({this.props.data[cs.ModelTemplates].length}) </h1></Col>
<Col span={2}>
<Button type="primary" icon="plus" onClick={ this.handleShowCreateForm }>Add template</Button>
</Col>
</Row>
<br />
<Table
columns={ this.columns }
rowKey={ record => record.id }
dataSource={ this.props.data[cs.ModelTemplates] }
loading={ this.props.reqStates[cs.ModelTemplates] !== cs.StateDone }
pagination={ false }
/>
<CreateForm { ...this.props }
formType={ this.state.formType }
record={ this.state.record }
onClose={ this.hideForm }
fetchRecords = { this.fetchRecords }
/>
<Modal visible={ this.state.previewRecord !== null } title={ this.state.previewRecord ? this.state.previewRecord.name : "" }
className="template-preview-modal"
width="90%"
height={ 900 }
onOk={ () => { this.setState({ previewRecord: null }) } }>
{ this.state.previewRecord !== null &&
<iframe title="Template preview"
className="template-preview"
src={ cs.Routes.PreviewTemplate.replace(":id", this.state.previewRecord.id) }>
</iframe> }
</Modal>
</section>
)
}
}
export default Templates

41
frontend/my/src/Test.js Normal file
View File

@ -0,0 +1,41 @@
import React from "react";
import ReactQuill from "react-quill"
import "react-quill/dist/quill.snow.css"
const quillModules = {
toolbar: {
container: [
[{"header": [1, 2, 3, false] }],
["bold", "italic", "underline", "strike", "blockquote", "code"],
[{ "color": [] }, { "background": [] }, { 'size': [] }],
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}],
[{"align": ""}, { "align": "center" }, { "align": "right" }, { "align": "justify" }],
["link", "gallery"],
["clean", "font"]
],
handlers: {
"gallery": function() {
}
}
}
}
class QuillEditor extends React.Component {
componentDidMount() {
}
render() {
return (
<div>
<ReactQuill
modules={ quillModules }
value="<h2>Welcome</h2>"
/>
</div>
)
}
}
export default QuillEditor;

View File

@ -0,0 +1,92 @@
export const DateFormat = "ddd D MMM YYYY, hh:MM A"
// Data types.
export const ModelUsers = "users"
export const ModelSubscribers = "subscribers"
export const ModelSubscribersByList = "subscribersByList"
export const ModelLists = "lists"
export const ModelMedia = "media"
export const ModelCampaigns = "campaigns"
export const ModelTemplates = "templates"
// HTTP methods.
export const MethodGet = "get"
export const MethodPost = "post"
export const MethodPut = "put"
export const MethodDelete = "delete"
// Data loading states.
export const StatePending = "pending"
export const StateDone = "done"
// Form types.
export const FormCreate = "create"
export const FormEdit = "edit"
// Message types.
export const MsgSuccess = "success"
export const MsgWarning = "warning"
export const MsgError = "error"
// Model specific.
export const CampaignStatusColors = {
draft: "",
scheduled: "purple",
running: "blue",
paused: "orange",
finished: "green",
cancelled: "red",
}
export const CampaignStatusDraft = "draft"
export const CampaignStatusScheduled = "scheduled"
export const CampaignStatusRunning = "running"
export const CampaignStatusPaused = "paused"
export const CampaignStatusFinished = "finished"
export const CampaignStatusCancelled = "cancelled"
export const SubscriptionStatusConfirmed = "confirmed"
export const SubscriptionStatusUnConfirmed = "unconfirmed"
export const SubscriptionStatusUnsubscribed = "unsubscribed"
// API routes.
export const Routes = {
GetUsers: "/api/users",
GetLists: "/api/lists",
CreateList: "/api/lists",
UpdateList: "/api/lists/:id",
DeleteList: "/api/lists/:id",
GetSubscribers: "/api/subscribers",
GetSubscribersByList: "/api/subscribers/lists/:listID",
CreateSubscriber: "/api/subscribers",
UpdateSubscriber: "/api/subscribers/:id",
DeleteSubscriber: "/api/subscribers/:id",
DeleteSubscribers: "/api/subscribers",
QuerySubscribersIntoLists: "/api/subscribers/lists",
ViewCampaign: "/campaigns/:id",
GetCampaignMessengers: "/api/campaigns/messengers",
GetCampaigns: "/api/campaigns",
GetRunningCampaignStats: "/api/campaigns/running/stats",
CreateCampaign: "/api/campaigns",
UpdateCampaign: "/api/campaigns/:id",
UpdateCampaignStatus: "/api/campaigns/:id/status",
DeleteCampaign: "/api/campaigns/:id",
GetMedia: "/api/media",
AddMedia: "/api/media",
DeleteMedia: "/api/media/:id",
GetTemplates: "/api/templates",
PreviewTemplate: "/api/templates/:id/preview",
CreateTemplate: "/api/templates",
UpdateTemplate: "/api/templates/:id",
SetDefaultTemplate: "/api/templates/:id/default",
DeleteTemplate: "/api/templates/:id",
UploadRouteImport: "/api/import/subscribers",
GetRouteImportStats: "/api/import/subscribers",
GetRouteImportLogs: "/api/import/subscribers/logs"
}

264
frontend/my/src/index.css Normal file
View File

@ -0,0 +1,264 @@
/* Disable all the ridiculous, unnecessary animations except for the spinner */
*:not(.ant-spin-dot-spin) {
animation-duration: 0s;
transition: none !important;
}
header.header {
margin-bottom: 30px;
}
hr {
border-width: 1px 0 0 0;
border-style: solid;
border-color: #eee;
margin: 30px 0;
}
/* Helpers */
.center {
text-align: center;
}
.text-tiny {
font-size: 0.65em;
}
.text-small {
font-size: 0.85em;
}
.text-grey {
color: #999;
}
/* Layout */
body {
margin: 0;
padding: 0;
font-family: sans-serif;
}
.content-body {
background: #fff;
padding: 24px;
min-height: 90vh;
}
.logo {
padding: 30px;
}
.logo a {
overflow: hidden;
display: inline-block;
}
.logo img {
width: auto;
height: 22px;
}
.ant-layout-sider.ant-layout-sider-collapsed .logo a {
width: 20px;
}
.hidden {
display: none;
}
/* Table actions */
td .actions a {
display: inline-block;
padding: 10px;
}
td.actions {
text-align: right;
}
/* Templates */
.wysiwyg {
padding: 30px;
color: red;
}
/* Subscribers */
.subscriber-filter {
background: #fff;
min-width: 300px;
max-width: 500px;
width: 100%;
padding: 15px;
box-shadow: 0 1px 6px rgba(0, 0, 0, .2);
}
.subscriber-filter .lists {
width: 100%;
}
.subscribers table .name {
margin-bottom: 10px;
}
.subscriber-query {
margin: 15px 0 30px 0;
padding: 30px;
box-shadow: 0 1px 6px #ddd;
}
.subscriber-query textarea {
font-family: monospace;
}
.subscriber-query .actions {
margin-top: 15px;
}
.subscriber-modal #lists .status {
color: #999;
}
.subscriber-modal #lists .status.confirmed {
color: #52c41a;
}
.subscriber-modal #lists .status.unsubscribed {
color: #ff7875;
}
/* Import */
.import .import-container {
margin-top: 100px;
}
.import .logs,
.import .help {
max-width: 950px;
margin-top: 30px;
}
.import .stats .ant-progress {
margin-bottom: 30px;
}
.import .csv-example {
background: #efefef;
padding: 5px 10px;
display: inline-block;
}
.import .csv-example code {
display: block;
}
.import .csv-example .csv-headers span {
font-weight: bold;
}
/* Campaigns */
.campaigns table tbody td {
vertical-align: top;
border-bottom-width: 3px;
border-bottom-color: #efefef;
}
.campaigns td.status .date {
display: block;
margin-top: 5px;
}
.campaigns td.lists .name {
margin-right: 15px;
}
.campaigns td hr {
margin: 10px 0;
}
.campaigns td.stats .ant-row {
border-bottom: 1px solid #eee;
padding: 5px 0;
}
.campaigns td.stats .ant-row:last-child {
border: 0;
}
.campaigns td.stats .label {
font-weight: 600;
color: #aaa;
}
.campaigns .duration {
text-transform: capitalize;
}
.campaign .messengers {
text-transform: capitalize;
}
.campaign .content-type .actions {
display: inline-block;
margin-left: 15px;
}
.campaign .content-actions {
margin-top: 30px;
}
/* gallery */
.gallery {
display:flex;
flex-direction: row;
flex-flow: wrap;
}
.gallery .image {
display: flex;
align-items: center;
justify-content: center;
min-height: 90px;
padding: 10px;
border: 1px solid #eee;
overflow: hidden;
margin: 10px;
text-align: center;
position: relative;
}
.gallery .name {
background: rgba(255, 255, 255, 0.8);
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 3px 5px;
width: 100%;
font-size: .75em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.gallery .actions {
position: absolute;
top: 10px;
right: 15px;
display: none;
text-align: center;
}
.gallery .actions a {
background: rgba(255, 255, 255, 0.9);
padding: 0 3px 3px 3px;
border-radius: 0 0 3px 3px;
display: inline-block;
margin-left: 5px;
}
.gallery .image:hover .actions {
display: block;
}
.gallery .image img {
max-width: 90px;
max-height: 90px;
display: block;
}
/* gallery icon in the wsiwyg */
.ql-gallery {
background: url('/gallery.svg');
}
/* templates */
.templates .template-body {
margin-top: 30px;
}
.template-preview {
border: 0;
width: 100%;
height: 100%;
min-height: 500px;
}
.template-preview-modal .ant-modal-footer button:first-child {
display: none;
}

8
frontend/my/src/index.js Normal file
View File

@ -0,0 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App.js'
ReactDOM.render((<App />), document.getElementById('root'))

7
frontend/my/src/logo.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,117 @@
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ'
);
});
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl);
}
});
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

View File

@ -0,0 +1 @@
<svg viewbox="0 0 18 18"><rect class="ql-stroke" height="10" width="12" x="3" y="4"></rect><circle class="ql-fill" cx="6" cy="7" r="1"></circle><polyline class="ql-even ql-fill" points="5 12 5 11 7 9 8 10 11 7 13 9 13 12 5 12"></polyline></svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="41.535763mm"
height="8.3702965mm"
viewBox="0 0 41.535763 8.3702965"
version="1.1"
id="svg8"
sodipodi:docname="listmonk.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="41.789813"
inkscape:cy="42.352202"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1853"
inkscape:window-height="1025"
inkscape:window-x="67"
inkscape:window-y="27"
inkscape:window-maximized="1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-88.728827,-97.568868)">
<g
aria-label="listmonk"
style="font-style:normal;font-weight:normal;font-size:8.44721699px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.21118042"
id="text817">
<path
d="m 98.79092,104.90855 h 0.76025 v -6.3861 h -0.76025 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path944" />
<path
d="m 100.82023,104.90855 h 0.76025 v -4.00398 h -0.76025 z m -0.17739,-5.440011 c 0,0.3041 0.25342,0.557521 0.55752,0.557521 0.3041,0 0.55751,-0.253421 0.55751,-0.557521 0,-0.3041 -0.25341,-0.557517 -0.55751,-0.557517 -0.3041,0 -0.55752,0.253417 -0.55752,0.557517 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path946" />
<path
d="m 105.51042,101.46208 c -0.28721,-0.47304 -0.83628,-0.65888 -1.36845,-0.65888 -0.7518,0 -1.5205,0.39702 -1.5205,1.23329 0,0.7687 0.57441,0.97988 1.22485,1.14038 0.32944,0.076 0.96298,0.16049 0.96298,0.61664 0,0.34634 -0.42236,0.50684 -0.81093,0.50684 -0.43926,0 -0.72646,-0.22808 -0.97143,-0.47305 l -0.57441,0.47305 c 0.39701,0.54062 0.88695,0.70956 1.54584,0.70956 0.79403,0 1.62186,-0.35478 1.62186,-1.25863 0,-0.7518 -0.50683,-1.00522 -1.16571,-1.16572 -0.33789,-0.076 -1.02212,-0.1436 -1.02212,-0.62509 0,-0.28721 0.31255,-0.4477 0.66733,-0.4477 0.38013,0 0.64199,0.17739 0.81938,0.39701 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path948" />
<path
d="m 108.68116,100.90457 h -1.08969 v -1.123484 h -0.76025 v 1.123484 h -0.87851 v 0.65888 h 0.87851 v 2.08646 c 0,0.64199 0.0169,1.36 1.19106,1.36 0.15205,0 0.50683,-0.0338 0.68422,-0.13515 v -0.69267 c -0.15205,0.0929 -0.36323,0.11826 -0.54062,0.11826 -0.57441,0 -0.57441,-0.47305 -0.57441,-0.92075 v -1.81615 h 1.08969 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path950" />
<path
d="m 109.65378,104.90855 h 0.76025 v -2.07802 c 0,-0.90385 0.48994,-1.31776 1.039,-1.31776 0.73491,0 0.84473,0.54062 0.84473,1.30087 v 2.09491 h 0.76025 v -2.18783 c 0,-0.70957 0.2872,-1.20795 1.02211,-1.20795 0.73491,0 0.86162,0.55751 0.86162,1.16571 v 2.23007 h 0.76024 v -2.33144 c 0,-0.8954 -0.2872,-1.77391 -1.52049,-1.77391 -0.4815,0 -1.00522,0.25342 -1.26709,0.74335 -0.25341,-0.48993 -0.65888,-0.74335 -1.25018,-0.74335 -0.71802,0 -1.20796,0.48994 -1.28398,0.72646 h -0.0169 v -0.62509 h -0.70956 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path952" />
<path
d="m 117.46376,102.90656 c 0,-0.81094 0.50683,-1.39379 1.31776,-1.39379 0.81094,0 1.31777,0.58285 1.31777,1.39379 0,0.81093 -0.50683,1.39379 -1.31777,1.39379 -0.81093,0 -1.31776,-0.58286 -1.31776,-1.39379 z m -0.81094,0 c 0,1.16571 0.93765,2.10335 2.1287,2.10335 1.19106,0 2.1287,-0.93764 2.1287,-2.10335 0,-1.16572 -0.93764,-2.10336 -2.1287,-2.10336 -1.19105,0 -2.1287,0.93764 -2.1287,2.10336 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path954" />
<path
d="m 121.86265,104.90855 h 0.76024 v -2.06112 c 0,-0.9292 0.4815,-1.33466 1.11504,-1.33466 0.48149,0 0.9123,0.27875 0.9123,1.03056 v 2.36522 h 0.76025 v -2.5764 c 0,-1.06435 -0.67578,-1.52895 -1.45292,-1.52895 -0.61665,0 -1.12348,0.29565 -1.31777,0.71801 h -0.0169 v -0.61664 h -0.76024 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path956" />
<path
d="m 126.56471,104.90855 h 0.76025 v -2.07802 l 1.85839,2.07802 h 1.08124 l -2.02733,-2.17939 1.86683,-1.81615 h -1.0559 l -1.72323,1.73168 v -4.12224 h -0.76025 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path958" />
</g>
<circle
style="fill:none;fill-opacity:1;stroke:#7f2aff;stroke-width:1.05596864;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path874"
cx="92.280685"
cy="102.38731"
r="3.0238743" />
<path
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:1.15863311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 92.280686,99.698318 a 3.0238741,3.6404374 0 0 0 -3.023758,3.640282 3.0238741,3.6404374 0 0 0 0.0697,0.75725 3.0238741,3.6404374 0 0 1 2.954053,-2.87212 3.0238741,3.6404374 0 0 1 2.954051,2.88305 3.0238741,3.6404374 0 0 0 0.0697,-0.76818 3.0238741,3.6404374 0 0 0 -3.023756,-3.640282 z"
id="circle878"
inkscape:connector-curvature="0" />
<path
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:1.05596864;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path873"
sodipodi:type="arc"
sodipodi:cx="92.280685"
sodipodi:cy="-99.267464"
sodipodi:rx="1.6985958"
sodipodi:ry="1.6985958"
sodipodi:start="0"
sodipodi:end="3.1415927"
d="m 93.979281,-99.267464 a 1.6985958,1.6985958 0 0 1 -0.849298,1.471027 1.6985958,1.6985958 0 0 1 -1.698596,0 1.6985958,1.6985958 0 0 1 -0.849297,-1.471027 l 1.698595,0 z"
transform="scale(1,-1)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

59
frontend/my/src/utils.js Normal file
View File

@ -0,0 +1,59 @@
import React from 'react'
import ReactDOM from 'react-dom';
import { Alert } from 'antd';
class Utils {
static months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ]
static days = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ]
// Converts the ISO date format to a simpler form.
static DateString = (stamp, showTime) => {
if(!stamp) {
return ""
}
let d = new Date(stamp)
let out = Utils.days[d.getDay()] + ", " + d.getDate() + " " + Utils.months[d.getMonth()] + " " + d.getFullYear()
if(showTime) {
out += " " + d.getHours() + ":" + d.getMinutes()
}
return out
}
// HttpError takes an axios error and returns an error dict after some sanity checks.
static HttpError = (err) => {
if (!err.response) {
return err
}
if(!err.response.data || !err.response.data.message) {
return {
"message": err.message + " - " + err.response.request.responseURL,
"data": {}
}
}
return {
"message": err.response.data.message,
"data": err.response.data.data
}
}
// Shows a flash message.
static Alert = (msg, msgType) => {
document.getElementById('alert-container').classList.add('visible')
ReactDOM.render(<Alert message={ msg } type={ msgType } showIcon />,
document.getElementById('alert-container'))
}
static ModalAlert = (msg, msgType) => {
document.getElementById('modal-alert-container').classList.add('visible')
ReactDOM.render(<Alert message={ msg } type={ msgType } showIcon />,
document.getElementById('modal-alert-container'))
}
}
export default Utils

7982
frontend/my/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

6
frontend/package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {
"create-react-app": "^1.5.2",
"react": "^16.4.1"
}
}

15
generate-subs.py Normal file
View File

@ -0,0 +1,15 @@
import csv
import random
f = open("/tmp/subs.csv", "w+")
w = csv.writer(f)
w.writerow(["email", "name", "status", "tags", "attributes"])
for n in range(0, 100000):
w.writerow([
"user%d@mail.com" % (n,),
"First%d Last%d" % (n, n),
"enabled",
"apple|mango|orange",
"{\"age\": %d, \"city\": \"Bangalore\"}" % (random.randint(20,70),)
])

137
handlers.go Normal file
View File

@ -0,0 +1,137 @@
package main
import (
"encoding/json"
"net/url"
"path/filepath"
"strconv"
"strings"
"github.com/asaskevich/govalidator"
"github.com/gorilla/sessions"
"github.com/labstack/echo"
"github.com/labstack/echo-contrib/session"
)
const (
// stdInputMaxLen is the maximum allowed length for a standard input field.
stdInputMaxLen = 200
// bodyMaxLen is the maximum allowed length for e-mail bodies.
bodyMaxLen = 1000000
// defaultPerPage is the default number of results returned in an GET call.
defaultPerPage = 20
// maxPerPage is the maximum number of allowed for paginated records.
maxPerPage = 100
)
type okResp struct {
Data interface{} `json:"data"`
}
// pagination represents a query's pagination (limit, offset) related values.
type pagination struct {
PerPage int `json:"per_page"`
Page int `json:"page"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
// auth is a middleware that handles session authentication. If a session is not set,
// it creates one and redirects the user to the login page. If a session is set,
// it's authenticated before proceeding to the handler.
func authSession(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
sess, _ := session.Get("session", c)
// It's a brand new session. Persist it.
if sess.IsNew {
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
// Secure: true,
}
sess.Values["user_id"] = 1
sess.Values["user"] = "kailash"
sess.Values["role"] = "superadmin"
sess.Values["user_email"] = "kailash@zerodha.com"
sess.Save(c.Request(), c.Response())
}
return next(c)
}
}
// handleIndex is the root handler that renders the login page if there's no
// authenticated session, or redirects to the dashboard, if there's one.
func handleIndexPage(c echo.Context) error {
app := c.Get("app").(*App)
return c.File(filepath.Join(app.Constants.AssetPath, "index.html"))
}
// makeAttribsBlob takes a list of keys and values and creates
// a JSON map out of them.
func makeAttribsBlob(keys []string, vals []string) ([]byte, bool) {
attribs := make(map[string]interface{})
for i, key := range keys {
var (
s = vals[i]
val interface{}
)
// Try to detect common JSON types.
if govalidator.IsFloat(s) {
val, _ = strconv.ParseFloat(s, 64)
} else if govalidator.IsInt(s) {
val, _ = strconv.ParseInt(s, 10, 64)
} else {
ls := strings.ToLower(s)
if ls == "true" || ls == "false" {
val, _ = strconv.ParseBool(ls)
} else {
// It's a string.
val = s
}
}
attribs[key] = val
}
if len(attribs) > 0 {
j, _ := json.Marshal(attribs)
return j, true
}
return nil, false
}
// getPagination takes form values and extracts pagination values from it.
func getPagination(q url.Values) pagination {
var (
perPage, _ = strconv.Atoi(q.Get("per_page"))
page, _ = strconv.Atoi(q.Get("page"))
)
if perPage < 1 || perPage > maxPerPage {
perPage = defaultPerPage
}
if page < 1 {
page = 0
} else {
page--
}
return pagination{
Page: page + 1,
PerPage: perPage,
Offset: page * perPage,
Limit: perPage,
}
}

113
import.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"github.com/knadh/listmonk/subimporter"
"github.com/labstack/echo"
)
// reqImport represents file upload import params.
type reqImport struct {
Delim string `json:"delim"`
OverrideStatus bool `json:"override_status"`
ListIDs []int `json:"lists"`
}
// handleImportSubscribers handles the uploading and bulk importing of
// a ZIP file of one or more CSV files.
func handleImportSubscribers(c echo.Context) error {
app := c.Get("app").(*App)
// Is an import already running?
if app.Importer.GetStats().Status == subimporter.StatusImporting {
return echo.NewHTTPError(http.StatusBadRequest,
"An import is already running. Wait for it to finish or stop it before trying again.")
}
// Unmarsal the JSON params.
var r reqImport
if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Invalid `params` field: %v", err))
}
if len(r.Delim) != 1 {
return echo.NewHTTPError(http.StatusBadRequest,
"`delim` should be a single character")
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Invalid `file`: %v", err))
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
out, err := ioutil.TempFile("", "listmonk")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error copying uploaded file: %v", err))
}
defer out.Close()
if _, err = io.Copy(out, src); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error copying uploaded file: %v", err))
}
// Start the importer session.
impSess, err := app.Importer.NewSession(file.Filename,
r.OverrideStatus,
r.ListIDs)
if err != nil {
return err
}
go impSess.Start()
// For now, we only extract 1 CSV from the ZIP. Handling async CSV
// imports is more trouble than it's worth.
dir, files, err := impSess.ExtractZIP(out.Name(), 1)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error extracting ZIP file: %v", err))
}
go impSess.LoadCSV(dir+"/"+files[0], rune(r.Delim[0]))
return c.JSON(http.StatusOK, okResp{app.Importer.GetStats()})
}
// handleGetImportSubscribers returns import statistics.
func handleGetImportSubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
s = app.Importer.GetStats()
)
return c.JSON(http.StatusOK, okResp{s})
}
// handleGetImportSubscriberLogs returns import statistics.
func handleGetImportSubscriberLogs(c echo.Context) error {
app := c.Get("app").(*App)
return c.JSON(http.StatusOK, okResp{string(app.Importer.GetLogs())})
}
// handleStopImportSubscribers sends a stop signal to the importer.
// If there's an ongoing import, it'll be stopped, and if an import
// is finished, it's state is cleared.
func handleStopImportSubscribers(c echo.Context) error {
app := c.Get("app").(*App)
app.Importer.Stop()
return c.JSON(http.StatusOK, okResp{app.Importer.GetStats()})
}

169
install.go Normal file
View File

@ -0,0 +1,169 @@
package main
import (
"bytes"
"fmt"
"io/ioutil"
"regexp"
"syscall"
"github.com/lib/pq"
uuid "github.com/satori/go.uuid"
"github.com/jmoiron/sqlx"
"github.com/knadh/goyesql"
"github.com/knadh/listmonk/models"
"github.com/spf13/viper"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh/terminal"
)
// install runs the first time setup of creating and
// migrating the database and creating the super user.
func install(app *App, qMap goyesql.Queries) {
var (
email, pw, pw2 []byte
err error
// Pseudo e-mail validation using Regexp, well ...
emRegex, _ = regexp.Compile("(.+?)@(.+?)")
)
fmt.Println("** First time installation. **")
fmt.Println("** IMPORTANT: This will wipe existing listmonk tables and types. **")
fmt.Println("\n")
for len(email) == 0 {
fmt.Print("Enter the superadmin login e-mail: ")
if _, err = fmt.Scanf("%s", &email); err != nil {
logger.Fatalf("Error reading e-mail from the terminal: %v", err)
}
if !emRegex.Match(email) {
logger.Println("Please enter a valid e-mail")
email = []byte{}
}
}
for len(pw) < 8 {
fmt.Print("Enter the superadmin password (min 8 chars): ")
if pw, err = terminal.ReadPassword(int(syscall.Stdin)); err != nil {
logger.Fatalf("Error reading password from the terminal: %v", err)
}
fmt.Println("")
if len(pw) < 8 {
logger.Println("Password should be min 8 characters")
pw = []byte{}
}
}
for len(pw2) < 8 {
fmt.Print("Repeat the superadmin password: ")
if pw2, err = terminal.ReadPassword(int(syscall.Stdin)); err != nil {
logger.Fatalf("Error reading password from the terminal: %v", err)
}
fmt.Println("")
if len(pw2) < 8 {
logger.Println("Password should be min 8 characters")
pw2 = []byte{}
}
}
// Validate.
if !bytes.Equal(pw, pw2) {
logger.Fatalf("Passwords don't match")
}
// Hash the password.
hash, err := bcrypt.GenerateFromPassword(pw, bcrypt.DefaultCost)
if err != nil {
logger.Fatalf("Error hashing password: %v", err)
}
// Migrate the tables.
err = installMigrate(app.DB)
if err != nil {
logger.Fatalf("Error migrating DB schema: %v", err)
}
// Load the queries.
var q Queries
if err := scanQueriesToStruct(&q, qMap, app.DB.Unsafe()); err != nil {
logger.Fatalf("error loading SQL queries: %v", err)
}
// Create the superadmin user.
if _, err := q.CreateUser.Exec(
string(email),
models.UserTypeSuperadmin, // name
string(hash),
models.UserTypeSuperadmin,
models.UserStatusEnabled,
); err != nil {
logger.Fatalf("Error creating superadmin user: %v", err)
}
// Sample list.
var listID int
if err := q.CreateList.Get(&listID,
uuid.NewV4().String(),
"Default list",
models.ListTypePublic,
pq.StringArray{"test"},
); err != nil {
logger.Fatalf("Error creating superadmin user: %v", err)
}
// Sample subscriber.
name := bytes.Split(email, []byte("@"))
if _, err := q.UpsertSubscriber.Exec(
uuid.NewV4(),
email,
bytes.Title(name[0]),
models.SubscriberStatusEnabled,
`{"type": "known", "good": true}`,
true,
pq.Int64Array{int64(listID)},
); err != nil {
logger.Fatalf("Error creating subscriber: %v", err)
}
// Default template.
tplBody, err := ioutil.ReadFile("default-template.html")
if err != nil {
tplBody = []byte(`{{ template "content" . }}`)
}
var tplID int
if err := q.CreateTemplate.Get(&tplID,
"Default template",
string(tplBody),
); err != nil {
logger.Fatalf("Error creating default template: %v", err)
}
if _, err := q.SetDefaultTemplate.Exec(tplID); err != nil {
logger.Fatalf("Error setting default template: %v", err)
}
logger.Printf("Setup complete")
logger.Printf(`Run the program and login with the username "superadmin" and your password at %s`,
viper.GetString("server.address"))
}
// installMigrate executes the SQL schema and creates the necessary tables and types.
func installMigrate(db *sqlx.DB) error {
q, err := ioutil.ReadFile("schema.sql")
if err != nil {
return err
}
_, err = db.Query(string(q))
if err != nil {
return err
}
return nil
}

146
lists.go Normal file
View File

@ -0,0 +1,146 @@
package main
import (
"fmt"
"net/http"
"strconv"
"github.com/knadh/listmonk/models"
"github.com/lib/pq"
uuid "github.com/satori/go.uuid"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo"
)
// handleGetLists handles retrieval of lists.
func handleGetLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []models.List
listID, _ = strconv.Atoi(c.Param("id"))
single = false
)
// Fetch one list.
if listID > 0 {
single = true
}
err := app.Queries.GetLists.Select(&out, listID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching lists: %s", pqErrMsg(err)))
} else if single && len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "List not found.")
} else if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
// Replace null tags.
for i, v := range out {
if v.Tags == nil {
out[i].Tags = make(pq.StringArray, 0)
}
}
if single {
return c.JSON(http.StatusOK, okResp{out[0]})
}
return c.JSON(http.StatusOK, okResp{out})
}
// handleCreateList handles list creation.
func handleCreateList(c echo.Context) error {
var (
app = c.Get("app").(*App)
o = models.List{}
)
if err := c.Bind(&o); err != nil {
return err
}
// Validate.
if !govalidator.IsByteLength(o.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest,
"Invalid length for the name field.")
}
// Insert and read ID.
var newID int
o.UUID = uuid.NewV4().String()
if err := app.Queries.CreateList.Get(&newID,
o.UUID,
o.Name,
o.Type,
pq.StringArray(normalizeTags(o.Tags))); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating list: %s", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", newID))
return c.JSON(http.StatusOK, handleGetLists(c))
}
// handleUpdateList handles list modification.
func handleUpdateList(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
// Incoming params.
var o models.List
if err := c.Bind(&o); err != nil {
return err
}
res, err := app.Queries.UpdateList.Exec(id, o.Name, o.Type, pq.StringArray(normalizeTags(o.Tags)))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error updating list: %s", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "List not found.")
}
return handleGetLists(c)
}
// handleDeleteLists handles deletion deletion,
// either a single one (ID in the URI), or a list.
func handleDeleteLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
ids pq.Int64Array
)
// Read the list IDs if they were sent in the body.
c.Bind(&ids)
if id < 1 && len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
if id > 0 {
ids = append(ids, id)
}
if _, err := app.Queries.DeleteLists.Exec(ids); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Delete failed: %v", err))
}
return c.JSON(http.StatusOK, okResp{true})
}

258
main.go Normal file
View File

@ -0,0 +1,258 @@
package main
import (
"fmt"
"html/template"
"log"
"os"
"path/filepath"
"time"
_ "github.com/jinzhu/gorm/dialects/postgres"
"github.com/jmoiron/sqlx"
"github.com/knadh/goyesql"
"github.com/knadh/listmonk/messenger"
"github.com/knadh/listmonk/runner"
"github.com/knadh/listmonk/subimporter"
"github.com/labstack/echo"
flag "github.com/spf13/pflag"
"github.com/spf13/viper"
)
var logger *log.Logger
type constants struct {
AssetPath string `mapstructure:"asset_path"`
RootURL string `mapstructure:"root"`
UploadPath string `mapstructure:"upload_path"`
UploadURI string `mapstructure:"upload_uri"`
}
// App contains the "global" components that are
// passed around, especially through HTTP handlers.
type App struct {
Constants *constants
DB *sqlx.DB
Queries *Queries
Importer *subimporter.Importer
Runner *runner.Runner
Logger *log.Logger
}
func init() {
logger = log.New(os.Stdout, "SYS: ", log.Ldate|log.Ltime|log.Lshortfile)
// Register --help handler.
flagSet := flag.NewFlagSet("config", flag.ContinueOnError)
flagSet.Usage = func() {
fmt.Println(flagSet.FlagUsages())
os.Exit(0)
}
// Setup the default configuration.
viper.SetConfigName("config")
flagSet.StringSlice("config", []string{"config.toml"},
"Path to one or more config files (will be merged in order)")
flagSet.Bool("install", false, "Run first time installation")
flagSet.Bool("version", false, "Current version of the build")
// Process flags.
flagSet.Parse(os.Args[1:])
viper.BindPFlags(flagSet)
// Read the config files.
cfgs := viper.GetStringSlice("config")
for _, c := range cfgs {
logger.Printf("reading config: %s", c)
viper.SetConfigFile(c)
if err := viper.MergeInConfig(); err != nil {
logger.Fatalf("error reading config: %s", err)
}
}
}
// registerHandlers registers HTTP handlers.
func registerHandlers(e *echo.Echo) {
e.GET("/", handleIndexPage)
e.GET("/api/users", handleGetUsers)
e.POST("/api/users", handleCreateUser)
e.DELETE("/api/users/:id", handleDeleteUser)
e.GET("/api/subscribers/:id", handleGetSubscriber)
e.GET("/api/subscribers", handleQuerySubscribers)
e.POST("/api/subscribers", handleCreateSubscriber)
e.PUT("/api/subscribers/:id", handleUpdateSubscriber)
e.DELETE("/api/subscribers/:id", handleDeleteSubscribers)
e.DELETE("/api/subscribers", handleDeleteSubscribers)
e.POST("/api/subscribers/lists", handleQuerySubscribersIntoLists)
e.GET("/api/import/subscribers", handleGetImportSubscribers)
e.GET("/api/import/subscribers/logs", handleGetImportSubscriberLogs)
e.POST("/api/import/subscribers", handleImportSubscribers)
e.DELETE("/api/import/subscribers", handleStopImportSubscribers)
e.GET("/api/lists", handleGetLists)
e.GET("/api/lists/:id", handleGetLists)
e.POST("/api/lists", handleCreateList)
e.PUT("/api/lists/:id", handleUpdateList)
e.DELETE("/api/lists/:id", handleDeleteLists)
e.GET("/api/campaigns", handleGetCampaigns)
e.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
e.GET("/api/campaigns/messengers", handleGetCampaignMessengers)
e.GET("/api/campaigns/:id", handleGetCampaigns)
e.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
e.POST("/api/campaigns", handleCreateCampaign)
e.PUT("/api/campaigns/:id", handleUpdateCampaign)
e.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
e.DELETE("/api/campaigns/:id", handleDeleteCampaign)
e.GET("/api/media", handleGetMedia)
e.POST("/api/media", handleUploadMedia)
e.DELETE("/api/media/:id", handleDeleteMedia)
e.GET("/api/templates", handleGetTemplates)
e.GET("/api/templates/:id", handleGetTemplates)
e.GET("/api/templates/:id/preview", handlePreviewTemplate)
e.POST("/api/templates", handleCreateTemplate)
e.PUT("/api/templates/:id", handleUpdateTemplate)
e.PUT("/api/templates/:id/default", handleTemplateSetDefault)
e.DELETE("/api/templates/:id", handleDeleteTemplate)
// Subscriber facing views.
e.GET("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
e.POST("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
// Static views.
e.GET("/lists", handleIndexPage)
e.GET("/subscribers", handleIndexPage)
e.GET("/subscribers/lists/:listID", handleIndexPage)
e.GET("/subscribers/import", handleIndexPage)
e.GET("/campaigns", handleIndexPage)
e.GET("/campaigns/new", handleIndexPage)
e.GET("/campaigns/media", handleIndexPage)
e.GET("/campaigns/templates", handleIndexPage)
e.GET("/campaigns/:campignID", handleIndexPage)
}
// initMessengers initializes various messaging backends.
func initMessengers(r *runner.Runner) {
// Load SMTP configurations for the default e-mail Messenger.
var srv []messenger.Server
for name := range viper.GetStringMapString("smtp") {
if !viper.GetBool(fmt.Sprintf("smtp.%s.enabled", name)) {
logger.Printf("skipped SMTP config %s", name)
continue
}
var s messenger.Server
viper.UnmarshalKey("smtp."+name, &s)
s.Name = name
s.SendTimeout = s.SendTimeout * time.Millisecond
srv = append(srv, s)
logger.Printf("loaded SMTP config %s (%s@%s)", s.Name, s.Username, s.Host)
}
e, err := messenger.NewEmailer(srv...)
if err != nil {
logger.Fatalf("error loading e-mail messenger: %v", err)
}
if err := r.AddMessenger(e); err != nil {
logger.Printf("error registering messenger %s", err)
}
}
func main() {
// Connect to the DB.
db, err := connectDB(viper.GetString("db.host"),
viper.GetInt("db.port"),
viper.GetString("db.user"),
viper.GetString("db.password"),
viper.GetString("db.database"))
if err != nil {
logger.Fatalf("error connecting to DB: %v", err)
}
defer db.Close()
var c constants
viper.UnmarshalKey("app", &c)
c.AssetPath = filepath.Clean(viper.GetString("app.asset_path"))
// Initialize the app context that's passed around.
app := &App{
Constants: &c,
DB: db,
Logger: logger,
}
// Load SQL queries.
qMap, err := goyesql.ParseFile("queries.sql")
if err != nil {
logger.Fatalf("error loading SQL queries: %v", err)
}
// First time installation.
if viper.GetBool("install") {
install(app, qMap)
return
}
// Map queries to the query container.
q := &Queries{}
if err := scanQueriesToStruct(q, qMap, db.Unsafe()); err != nil {
logger.Fatalf("no SQL queries loaded: %v", err)
}
app.Queries = q
app.Importer = subimporter.New(q.UpsertSubscriber.Stmt, db.DB)
// Campaign daemon.
r := runner.New(runner.Config{
Concurrency: viper.GetInt("app.concurrency"),
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
UnsubscribeURL: fmt.Sprintf("%s/unsubscribe/%%s/%%s", app.Constants.RootURL),
}, newRunnerDB(q), logger)
app.Runner = r
// Add messengers.
initMessengers(app.Runner)
go r.Run(time.Duration(time.Second * 2))
r.SpawnWorkers()
// Initialize the server.
var srv = echo.New()
srv.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("app", app)
return next(c)
}
})
// User facing templates.
tpl, err := template.ParseGlob("public/templates/*.html")
if err != nil {
logger.Fatalf("error parsing public templates: %v", err)
}
srv.Renderer = &Template{
templates: tpl,
}
srv.HideBanner = true
// Register HTTP middleware.
// e.Use(session.Middleware(sessions.NewCookieStore([]byte("secret"))))
// e.Use(authSession)
srv.Static("/static", filepath.Join(filepath.Clean(viper.GetString("app.asset_path")), "static"))
srv.Static("/static/public", "frontend/my/public")
srv.Static("/public/static", "public/static")
srv.Static(filepath.Clean(viper.GetString("app.upload_uri")),
filepath.Clean(viper.GetString("app.upload_path")))
registerHandlers(srv)
srv.Logger.Fatal(srv.Start(viper.GetString("app.address")))
}

112
media.go Normal file
View File

@ -0,0 +1,112 @@
package main
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/disintegration/imaging"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo"
uuid "github.com/satori/go.uuid"
)
var imageMimes = []string{"image/jpg", "image/jpeg", "image/png", "image/svg", "image/gif"}
const (
thumbPrefix = "thumb_"
thumbWidth = 90
thumbHeight = 90
)
// handleUploadMedia handles media file uploads.
func handleUploadMedia(c echo.Context) error {
var (
app = c.Get("app").(*App)
cleanUp = false
)
// Upload the file.
fName, err := uploadFile("file", app.Constants.UploadPath, "", imageMimes, c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error uploading file: %s", err))
}
path := filepath.Join(app.Constants.UploadPath, fName)
defer func() {
// If any of the subroutines in this function fail,
// the uploaded image should be removed.
if cleanUp {
os.Remove(path)
}
}()
// Create a thumbnail.
src, err := imaging.Open(path)
if err != nil {
cleanUp = true
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error opening image for resizing: %s", err))
}
t := imaging.Resize(src, thumbWidth, 0, imaging.Lanczos)
if err := imaging.Save(t, fmt.Sprintf("%s/%s%s", app.Constants.UploadPath, thumbPrefix, fName)); err != nil {
cleanUp = true
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error saving thumbnail: %s", err))
}
// Write to the DB.
if _, err := app.Queries.InsertMedia.Exec(uuid.NewV4(), fName, fmt.Sprintf("%s%s", thumbPrefix, fName), 0, 0); err != nil {
cleanUp = true
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error saving uploaded file: %s", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleGetMedia handles retrieval of uploaded media.
func handleGetMedia(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []models.Media
)
if err := app.Queries.GetMedia.Select(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching media list: %s", pqErrMsg(err)))
}
for i := 0; i < len(out); i++ {
out[i].URI = fmt.Sprintf("%s/%s", app.Constants.UploadURI, out[i].Filename)
out[i].ThumbURI = fmt.Sprintf("%s/%s%s", app.Constants.UploadURI, thumbPrefix, out[i].Filename)
}
return c.JSON(http.StatusOK, okResp{out})
}
// deleteMedia handles deletion of uploaded media.
func handleDeleteMedia(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
var m models.Media
if err := app.Queries.DeleteMedia.Get(&m, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error deleting media: %s", pqErrMsg(err)))
}
os.Remove(filepath.Join(app.Constants.UploadPath, m.Filename))
os.Remove(filepath.Join(app.Constants.UploadPath, fmt.Sprintf("%s%s", thumbPrefix, m.Filename)))
return c.JSON(http.StatusOK, okResp{true})
}

94
messenger/emailer.go Normal file
View File

@ -0,0 +1,94 @@
package messenger
import (
"fmt"
"math/rand"
"net/smtp"
"time"
"github.com/jordan-wright/email"
)
const emName = "email"
// Server represents an SMTP server's credentials.
type Server struct {
Name string
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
AuthProtocol string `mapstructure:"auth_protocol"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
SendTimeout time.Duration `mapstructure:"send_timeout"`
MaxConns int `mapstructure:"max_conns"`
mailer *email.Pool
}
type emailer struct {
servers map[string]*Server
serverNames []string
numServers int
}
// NewEmailer creates and returns an e-mail Messenger backend.
// It takes multiple SMTP configurations.
func NewEmailer(srv ...Server) (Messenger, error) {
e := &emailer{
servers: make(map[string]*Server),
}
for _, s := range srv {
var auth smtp.Auth
if s.AuthProtocol == "cram" {
auth = smtp.CRAMMD5Auth(s.Username, s.Password)
} else {
auth = smtp.PlainAuth("", s.Username, s.Password, s.Host)
}
pool, err := email.NewPool(fmt.Sprintf("%s:%d", s.Host, s.Port), 4, auth)
if err != nil {
return nil, err
}
s.mailer = pool
e.servers[s.Name] = &s
e.serverNames = append(e.serverNames, s.Name)
}
e.numServers = len(e.serverNames)
return e, nil
}
// Name returns the Server's name.
func (e *emailer) Name() string {
return emName
}
// Push pushes a message to the server.
func (e *emailer) Push(fromAddr, toAddr, subject string, m []byte) error {
var key string
// If there are more than one SMTP servers, send to a random
// one from the list.
if e.numServers > 1 {
key = e.serverNames[rand.Intn(e.numServers)]
} else {
key = e.serverNames[0]
}
srv := e.servers[key]
err := srv.mailer.Send(&email.Email{
From: fromAddr,
To: []string{toAddr},
Subject: subject,
HTML: m,
}, srv.SendTimeout)
return err
}
// Flush flushes the message queue to the server.
func (e *emailer) Flush() error {
return nil
}

10
messenger/messenger.go Normal file
View File

@ -0,0 +1,10 @@
package messenger
// Messenger is an interface for a generic messaging backend,
// for instance, e-mail, SMS etc.
type Messenger interface {
Name() string
Push(fromAddr, toAddr, subject string, message []byte) error
Flush() error
}

189
models/models.go Normal file
View File

@ -0,0 +1,189 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"html/template"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/lib/pq"
null "gopkg.in/volatiletech/null.v6"
)
// Enum values for various statuses.
const (
// Subscriber.
SubscriberStatusEnabled = "enabled"
SubscriberStatusDisabled = "disabled"
SubscriberStatusBlackListed = "blacklisted"
// Campaign.
CampaignStatusDraft = "draft"
CampaignStatusScheduled = "scheduled"
CampaignStatusRunning = "running"
CampaignStatusPaused = "paused"
CampaignStatusFinished = "finished"
CampaignStatusCancelled = "cancelled"
// List.
ListTypePrivate = "private"
ListTypePublic = "public"
// User.
UserTypeSuperadmin = "superadmin"
UserTypeUser = "user"
UserStatusEnabled = "enabled"
UserStatusDisabled = "disabled"
)
// Base holds common fields shared across models.
type Base struct {
ID int `db:"id" json:"id"`
CreatedAt null.Time `db:"created_at" json:"created_at"`
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
}
// User represents an admin user.
type User struct {
Base
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"-"`
Type string `json:"type"`
Status string `json:"status"`
}
// Subscriber represents an e-mail subscriber.
type Subscriber struct {
Base
UUID string `db:"uuid" json:"uuid"`
Email string `db:"email" json:"email"`
Name string `db:"name" json:"name"`
Attribs SubscriberAttribs `db:"attribs" json:"attribs"`
Status string `db:"status" json:"status"`
CampaignIDs pq.Int64Array `db:"campaigns" json:"-"`
Lists []List `json:"lists"`
}
// SubscriberAttribs is the map of key:value attributes of a subscriber.
type SubscriberAttribs map[string]interface{}
// Subscribers represents a slice of Subscriber.
type Subscribers []Subscriber
// List represents a mailing list.
type List struct {
Base
UUID string `db:"uuid" json:"uuid"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
Tags pq.StringArray `db:"tags" json:"tags"`
SubscriberCount int `db:"subscriber_count" json:"subscriber_count"`
SubscriberID int `db:"subscriber_id" json:"-"`
// This is only relevant when querying the lists of a subscriber.
SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"`
}
// Campaign represents an e-mail campaign.
type Campaign struct {
Base
CampaignMeta
UUID string `db:"uuid" json:"uuid"`
Name string `db:"name" json:"name"`
Subject string `db:"subject" json:"subject"`
FromEmail string `db:"from_email" json:"from_email"`
Body string `db:"body" json:"body,omitempty"`
SendAt null.Time `db:"send_at" json:"send_at"`
Status string `db:"status" json:"status"`
ContentType string `db:"content_type" json:"content_type"`
Tags pq.StringArray `db:"tags" json:"tags"`
TemplateID int `db:"template_id" json:"template_id"`
MessengerID string `db:"messenger" json:"messenger"`
Lists types.JSONText `json:"lists"`
// TemplateBody is joined in from templates by the next-campaigns query.
TemplateBody string `db:"template_body" json:"-"`
Tpl *template.Template `json:"-"`
}
// CampaignMeta contains fields tracking a campaign's progress.
type CampaignMeta struct {
StartedAt null.Time `db:"started_at" json:"started_at"`
ToSend int `db:"to_send" json:"to_send"`
Sent int `db:"sent" json:"sent"`
}
// Campaigns represents a slice of Campaign.
type Campaigns []Campaign
// Media represents an uploaded media item.
type Media struct {
ID int `db:"id" json:"id"`
UUID string `db:"uuid" json:"uuid"`
Filename string `db:"filename" json:"filename"`
Width int `db:"width" json:"width"`
Height int `db:"height" json:"height"`
CreatedAt null.Time `db:"created_at" json:"created_at"`
ThumbURI string `json:"thumb_uri"`
URI string `json:"uri"`
}
// Template represents a reusable e-mail template.
type Template struct {
Base
Name string `db:"name" json:"name"`
Body string `db:"body" json:"body,omitempty"`
IsDefault bool `db:"is_default" json:"is_default"`
}
// LoadLists lazy loads the lists for all the subscribers
// in the Subscribers slice and attaches them to their []Lists property.
func (subs Subscribers) LoadLists(stmt *sqlx.Stmt) error {
var (
lists []List
subIDs = make([]int, len(subs))
)
for i := 0; i < len(subs); i++ {
subIDs[i] = subs[i].ID
subs[i].Lists = make([]List, 0)
}
err := stmt.Select(&lists, pq.Array(subIDs))
if err != nil {
return err
}
// Loop through each list and attach it to the subscribers by ID.
for _, l := range lists {
for i := 0; i < len(subs); i++ {
if l.SubscriberID == subs[i].ID {
subs[i].Lists = append(subs[i].Lists, l)
}
}
}
return nil
}
// Value returns the JSON marshalled SubscriberAttribs.
func (s SubscriberAttribs) Value() (driver.Value, error) {
return json.Marshal(s)
}
// Scan unmarshals JSON into SubscriberAttribs.
func (s SubscriberAttribs) Scan(src interface{}) error {
if data, ok := src.([]byte); ok {
return json.Unmarshal(data, &s)
}
return fmt.Errorf("Could not not decode type %T -> %T", src, s)
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"qs": "^6.5.2"
}
}

80
public.go Normal file
View File

@ -0,0 +1,80 @@
package main
import (
"html/template"
"io"
"net/http"
"regexp"
"strconv"
"github.com/labstack/echo"
)
type Template struct {
templates *template.Template
}
type publicTpl struct {
Title string
Description string
}
type unsubTpl struct {
publicTpl
Blacklisted bool
}
type errorTpl struct {
publicTpl
ErrorTitle string
ErrorMessage string
}
var regexValidUUID = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
// fmt.Println(t.templates.ExecuteTemplate(os.Stdout, name, nil))
return t.templates.ExecuteTemplate(w, name, data)
}
// handleUnsubscribePage unsubscribes a subscriber and renders a view.
func handleUnsubscribePage(c echo.Context) error {
var (
app = c.Get("app").(*App)
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
blacklist, _ = strconv.ParseBool(c.FormValue("blacklist"))
out = unsubTpl{}
)
out.Blacklisted = blacklist
out.Title = "Unsubscribe from mailing list"
if !regexValidUUID.MatchString(campUUID) ||
!regexValidUUID.MatchString(subUUID) {
err := errorTpl{}
err.Title = "Invalid request"
err.ErrorTitle = err.Title
err.ErrorMessage = "The unsubscription request contains invalid IDs. Please make sure to follow the correct link."
return c.Render(http.StatusBadRequest, "error", err)
}
// Unsubscribe.
res, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist)
if err != nil {
app.Logger.Printf("Error unsubscribing : %v", err)
return echo.NewHTTPError(http.StatusBadRequest, "Subscription doesn't exist")
}
num, err := res.RowsAffected()
if num == 0 {
err := errorTpl{}
err.Title = "Invalid subscription"
err.ErrorTitle = err.Title
err.ErrorMessage = "Looks like you are not subscribed to this mailing list."
return c.Render(http.StatusBadRequest, "error", err)
}
return c.Render(http.StatusOK, "unsubscribe", out)
}

121
public/static/logo.svg Normal file
View File

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="41.535763mm"
height="8.3702965mm"
viewBox="0 0 41.535763 8.3702965"
version="1.1"
id="svg8"
sodipodi:docname="listmonk.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="41.789813"
inkscape:cy="42.352202"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1853"
inkscape:window-height="1025"
inkscape:window-x="67"
inkscape:window-y="27"
inkscape:window-maximized="1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-88.728827,-97.568868)">
<g
aria-label="listmonk"
style="font-style:normal;font-weight:normal;font-size:8.44721699px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.21118042"
id="text817">
<path
d="m 98.79092,104.90855 h 0.76025 v -6.3861 h -0.76025 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path944" />
<path
d="m 100.82023,104.90855 h 0.76025 v -4.00398 h -0.76025 z m -0.17739,-5.440011 c 0,0.3041 0.25342,0.557521 0.55752,0.557521 0.3041,0 0.55751,-0.253421 0.55751,-0.557521 0,-0.3041 -0.25341,-0.557517 -0.55751,-0.557517 -0.3041,0 -0.55752,0.253417 -0.55752,0.557517 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path946" />
<path
d="m 105.51042,101.46208 c -0.28721,-0.47304 -0.83628,-0.65888 -1.36845,-0.65888 -0.7518,0 -1.5205,0.39702 -1.5205,1.23329 0,0.7687 0.57441,0.97988 1.22485,1.14038 0.32944,0.076 0.96298,0.16049 0.96298,0.61664 0,0.34634 -0.42236,0.50684 -0.81093,0.50684 -0.43926,0 -0.72646,-0.22808 -0.97143,-0.47305 l -0.57441,0.47305 c 0.39701,0.54062 0.88695,0.70956 1.54584,0.70956 0.79403,0 1.62186,-0.35478 1.62186,-1.25863 0,-0.7518 -0.50683,-1.00522 -1.16571,-1.16572 -0.33789,-0.076 -1.02212,-0.1436 -1.02212,-0.62509 0,-0.28721 0.31255,-0.4477 0.66733,-0.4477 0.38013,0 0.64199,0.17739 0.81938,0.39701 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path948" />
<path
d="m 108.68116,100.90457 h -1.08969 v -1.123484 h -0.76025 v 1.123484 h -0.87851 v 0.65888 h 0.87851 v 2.08646 c 0,0.64199 0.0169,1.36 1.19106,1.36 0.15205,0 0.50683,-0.0338 0.68422,-0.13515 v -0.69267 c -0.15205,0.0929 -0.36323,0.11826 -0.54062,0.11826 -0.57441,0 -0.57441,-0.47305 -0.57441,-0.92075 v -1.81615 h 1.08969 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path950" />
<path
d="m 109.65378,104.90855 h 0.76025 v -2.07802 c 0,-0.90385 0.48994,-1.31776 1.039,-1.31776 0.73491,0 0.84473,0.54062 0.84473,1.30087 v 2.09491 h 0.76025 v -2.18783 c 0,-0.70957 0.2872,-1.20795 1.02211,-1.20795 0.73491,0 0.86162,0.55751 0.86162,1.16571 v 2.23007 h 0.76024 v -2.33144 c 0,-0.8954 -0.2872,-1.77391 -1.52049,-1.77391 -0.4815,0 -1.00522,0.25342 -1.26709,0.74335 -0.25341,-0.48993 -0.65888,-0.74335 -1.25018,-0.74335 -0.71802,0 -1.20796,0.48994 -1.28398,0.72646 h -0.0169 v -0.62509 h -0.70956 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path952" />
<path
d="m 117.46376,102.90656 c 0,-0.81094 0.50683,-1.39379 1.31776,-1.39379 0.81094,0 1.31777,0.58285 1.31777,1.39379 0,0.81093 -0.50683,1.39379 -1.31777,1.39379 -0.81093,0 -1.31776,-0.58286 -1.31776,-1.39379 z m -0.81094,0 c 0,1.16571 0.93765,2.10335 2.1287,2.10335 1.19106,0 2.1287,-0.93764 2.1287,-2.10335 0,-1.16572 -0.93764,-2.10336 -2.1287,-2.10336 -1.19105,0 -2.1287,0.93764 -2.1287,2.10336 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path954" />
<path
d="m 121.86265,104.90855 h 0.76024 v -2.06112 c 0,-0.9292 0.4815,-1.33466 1.11504,-1.33466 0.48149,0 0.9123,0.27875 0.9123,1.03056 v 2.36522 h 0.76025 v -2.5764 c 0,-1.06435 -0.67578,-1.52895 -1.45292,-1.52895 -0.61665,0 -1.12348,0.29565 -1.31777,0.71801 h -0.0169 v -0.61664 h -0.76024 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path956" />
<path
d="m 126.56471,104.90855 h 0.76025 v -2.07802 l 1.85839,2.07802 h 1.08124 l -2.02733,-2.17939 1.86683,-1.81615 h -1.0559 l -1.72323,1.73168 v -4.12224 h -0.76025 z"
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Semi-Bold';fill:#000000;stroke-width:0.21118042"
id="path958" />
</g>
<circle
style="fill:none;fill-opacity:1;stroke:#7f2aff;stroke-width:1.05596864;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path874"
cx="92.280685"
cy="102.38731"
r="3.0238743" />
<path
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:1.15863311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 92.280686,99.698318 a 3.0238741,3.6404374 0 0 0 -3.023758,3.640282 3.0238741,3.6404374 0 0 0 0.0697,0.75725 3.0238741,3.6404374 0 0 1 2.954053,-2.87212 3.0238741,3.6404374 0 0 1 2.954051,2.88305 3.0238741,3.6404374 0 0 0 0.0697,-0.76818 3.0238741,3.6404374 0 0 0 -3.023756,-3.640282 z"
id="circle878"
inkscape:connector-curvature="0" />
<path
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:1.05596864;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path873"
sodipodi:type="arc"
sodipodi:cx="92.280685"
sodipodi:cy="-99.267464"
sodipodi:rx="1.6985958"
sodipodi:ry="1.6985958"
sodipodi:start="0"
sodipodi:end="3.1415927"
d="m 93.979281,-99.267464 a 1.6985958,1.6985958 0 0 1 -0.849298,1.471027 1.6985958,1.6985958 0 0 1 -1.698596,0 1.6985958,1.6985958 0 0 1 -0.849297,-1.471027 l 1.698595,0 z"
transform="scale(1,-1)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

0
public/static/script.js Normal file
View File

71
public/static/style.css Normal file
View File

@ -0,0 +1,71 @@
/* Flexit grid */
.container{position:relative;width:100%;max-width:960px;margin:0 auto;padding:0 10px;box-sizing:border-box}.row{box-sizing:border-box;display:flex;flex:0 1 auto;flex-flow:row wrap}.columns,.column{box-sizing:border-box;flex-grow:1;flex-shrink:1;flex-basis:1;margin:10px 0 10px 4%}.column:first-child,.columns:first-child{margin-left:0}.one{max-width:4.6666666667%}.two{max-width:13.3333333333%}.three{max-width:22%}.four{max-width:30.6666666667%}.five{max-width:39.3333333333%}.six{max-width:48%}.seven{max-width:56.6666666667%}.eight{max-width:65.3333333333%}.nine{max-width:74%}.ten{max-width:82.6666666667%}.eleven{max-width:91.3333333333%}.twelve{max-width:100%;margin-left:0}.column-offset-0{margin-left:0}.column-offset-1{margin-left:8.33333333%}.column-offset-2{margin-left:16.66666667%}.column-offset-3{margin-left:25%}.column-offset-4{margin-left:33.33333333%}.column-offset-5{margin-left:41.66666667%}.column-offset-6{margin-left:50%}.column-offset-7{margin-left:58.33333333%}.column-offset-8{margin-left:66.66666667%}.column-offset-9{margin-left:75%}.column-offset-10{margin-left:83.33333333%}.column-offset-11{margin-left:91.66666667%}.between{justify-content:space-between}.evenly{justify-content:space-evenly}.around{justify-content:space-around}.center{justify-content:center;text-align:center}.start{justify-content:flex-start}.end{justify-content:flex-end}.top{align-items:flex-start}.bottom{align-items:flex-end}.middle{align-items:center}.first{order:-1}.last{order:1}.vertical{flex-flow:column wrap}.row-align-center{align-items:center}.space-right{margin-right:10px}.space-left{margin-left:10px}.space-bottom{margin-bottom:10px}.space-top{margin-top:10px}@media screen and (max-width: 768px){.container{overflow:auto}.columns,.column{min-width:100%;margin:10px 0}.column-offset-0,.column-offset-1,.column-offset-2,.column-offset-3,.column-offset-4,.column-offset-5,.column-offset-6,.column-offset-7,.column-offset-8,.column-offset-9,.column-offset-10,.column-offset-11{margin:unset}}/*# sourceMappingURL=dist/flexit.min.css.map */
body {
background: #f9f9f9;
font-family: "Open Sans", "Helvetica Neue", sans-serif;
font-size: 16px;
line-height: 28px;
color: #111;
}
a {
color: #000;
}
h1, h2, h3, h4 {
font-weight: 400;
}
.button {
border: 0;
background: transparent;
padding: 10px 30px;
border-radius: 3px;
border: 1px solid #ddd;
cursor: pointer;
text-decoration: none;
color: #111;
display: inline-block;
}
.button:hover {
background: #f3f3f3;
}
.wrap {
background: #fff;
margin-top: 60px;
max-width: 600px;
padding: 45px;
box-shadow: 2px 2px 0 #f3f3f3;
border: 1px solid #eee;
}
.header {
margin-bottom: 60px;
}
.header .logo img {
width: auto;
max-height: 24px;
}
.unsub-all {
margin-top: 30px;
padding-top: 30px;
border-top: 1px solid #eee;
}
.footer {
text-align: center;
color: #aaa;
font-size: 0.775em;
margin-top: 30px;
margin-bottom: 30px;
}
.footer a {
color: #aaa;
text-decoration: none;
}
.footer a:hover {
color: #111;
}

View File

@ -0,0 +1,10 @@
{{ define "error" }}
{{ template "header" .}}
<h2>{{ .ErrorTitle }}</h2>
<div>
{{ .ErrorMessage }}
</div>
{{ template "footer" .}}
{{ end }}

View File

@ -0,0 +1,3 @@
{{ define "hello" }}
hello
{{ end }}

View File

@ -0,0 +1,3 @@
{{ define "hello" }}
hello2
{{ end }}

View File

@ -0,0 +1,46 @@
{{ define "header" }}
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>{{ .Title }}</title>
<meta name="description" content="{{ .Description }}" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400' rel='stylesheet' type='text/css'>
<link href="/public/static/style.css" rel="stylesheet" type="text/css" />
<link rel="shortcut icon" href="/public/static/favicon.png" type="image/x-icon" />
</head>
<body>
<div class="container wrap">
<header class="header">
<div class="logo"><img src="/public/static/logo.svg" /></div>
</header>
{{ end }}
{{ define "footer" }}
</div>
<div class="container">
<footer class="footer">
Powered by <a target="_blank" href="https://listmonk.app">listmonk</a>
</footer>
</div>
<script>
function unsubAll(e) {
if(!confirm("Are you sure?")) {
e.preventDefault();
return false;
}
}
(function() {
document.querySelector("#btn-unsuball").onclick = unsubAll
})();
</script>
</body>
</html>
{{ end }}

View File

@ -0,0 +1,22 @@
{{ define "unsubscribe" }}
{{ template "header" .}}
<h2>You have been unsubscribed</h2>
{{ if not .Blacklisted }}
<div class="unsub-all">
<p>
Unsubscribe from all future communications?
</p>
<form method="post">
<div>
<input type="hidden" name="blacklist" value="true" />
<button type="submit" class="button" id="btn-unsuball">Unsubscribe all</button>
</div>
</form>
</div>
{{ else }}
<p>You've been unsubscribed from all future communications.</p>
{{ end }}
{{ template "footer" .}}
{{ end }}

66
queries.go Normal file
View File

@ -0,0 +1,66 @@
package main
import (
"fmt"
"github.com/jmoiron/sqlx"
)
// Queries contains all prepared SQL queries.
type Queries struct {
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
QuerySubscribers string `query:"query-subscribers"`
QuerySubscribersCount string `query:"query-subscribers-count"`
QuerySubscribersByList string `query:"query-subscribers-by-list"`
QuerySubscribersByListCount string `query:"query-subscribers-by-list-count"`
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
QuerySubscribersIntoLists string `query:"query-subscribers-into-lists"`
CreateList *sqlx.Stmt `query:"create-list"`
GetLists *sqlx.Stmt `query:"get-lists"`
UpdateList *sqlx.Stmt `query:"update-list"`
DeleteLists *sqlx.Stmt `query:"delete-lists"`
CreateCampaign *sqlx.Stmt `query:"create-campaign"`
GetCampaigns *sqlx.Stmt `query:"get-campaigns"`
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
NextCampaigns *sqlx.Stmt `query:"next-campaigns"`
NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"`
GetOneCampaignSubscriber *sqlx.Stmt `query:"get-one-campaign-subscriber"`
UpdateCampaign *sqlx.Stmt `query:"update-campaign"`
UpdateCampaignStatus *sqlx.Stmt `query:"update-campaign-status"`
UpdateCampaignCounts *sqlx.Stmt `query:"update-campaign-counts"`
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`
CreateUser *sqlx.Stmt `query:"create-user"`
GetUsers *sqlx.Stmt `query:"get-users"`
UpdateUser *sqlx.Stmt `query:"update-user"`
DeleteUser *sqlx.Stmt `query:"delete-user"`
InsertMedia *sqlx.Stmt `query:"insert-media"`
GetMedia *sqlx.Stmt `query:"get-media"`
DeleteMedia *sqlx.Stmt `query:"delete-media"`
CreateTemplate *sqlx.Stmt `query:"create-template"`
GetTemplates *sqlx.Stmt `query:"get-templates"`
UpdateTemplate *sqlx.Stmt `query:"update-template"`
SetDefaultTemplate *sqlx.Stmt `query:"set-default-template"`
DeleteTemplate *sqlx.Stmt `query:"delete-template"`
// GetStats *sqlx.Stmt `query:"get-stats"`
}
// connectDB initializes a database connection.
func connectDB(host string, port int, user, pwd, dbName string) (*sqlx.DB, error) {
db, err := sqlx.Connect("postgres",
fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s", host, port, user, pwd, dbName))
if err != nil {
return nil, err
}
return db, nil
}

355
queries.sql Normal file
View File

@ -0,0 +1,355 @@
-- subscribers
-- name: get-subscriber
-- Get a single subscriber by id or UUID.
SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
-- name: get-subscriber-lists
-- Get lists belonging to subscribers.
SELECT lists.*, subscriber_lists.subscriber_id, subscriber_lists.status AS subscription_status FROM lists
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
WHERE subscriber_lists.subscriber_id = ANY($1::INT[]);
-- name: query-subscribers
-- raw: true
-- Unprepared statement for issuring arbitrary WHERE conditions.
SELECT * FROM subscribers WHERE 1=1 %s OFFSET %d LIMIT %d;
-- name: query-subscribers-count
-- raw: true
SELECT COUNT(id) as num FROM subscribers WHERE 1=1 %s;
-- name: query-subscribers-by-list
-- raw: true
-- Unprepared statement for issuring arbitrary WHERE conditions.
SELECT subscribers.* FROM subscribers INNER JOIN subscriber_lists
ON (subscriber_lists.subscriber_id = subscribers.id)
WHERE subscriber_lists.list_id = %d
%s
ORDER BY id DESC OFFSET %d LIMIT %d;
-- name: query-subscribers-by-list-count
-- raw: true
SELECT COUNT(subscribers.id) as num FROM subscribers INNER JOIN subscriber_lists
ON (subscriber_lists.subscriber_id = subscribers.id)
WHERE subscriber_lists.list_id = %d
%s;
-- name: upsert-subscriber
-- In case of updates, if $6 (override_status) is true, only then, the existing
-- value is overwritten with the incoming value. This is used for insertions and bulk imports.
WITH s AS (
INSERT INTO subscribers (uuid, email, name, status, attribs)
VALUES($1, $2, $3, $4, $5) ON CONFLICT (email) DO UPDATE
SET name=$3, status=(CASE WHEN $6 = true THEN $4 ELSE subscribers.status END),
attribs=$5, updated_at=NOW()
RETURNING id
) INSERT INTO subscriber_lists (subscriber_id, list_id)
VALUES((SELECT id FROM s), UNNEST($7::INT[]) )
ON CONFLICT (subscriber_id, list_id) DO NOTHING
RETURNING subscriber_id;
-- name: update-subscriber
-- Updates a subscriber's data, and given a list of list_ids, inserts subscriptions
-- for them while deleting existing subscriptions not in the list.
WITH s AS (
UPDATE subscribers SET
email=(CASE WHEN $2 != '' THEN $2 ELSE email END),
name=(CASE WHEN $3 != '' THEN $3 ELSE name END),
status=(CASE WHEN $4 != '' THEN $4::subscriber_status ELSE status END),
attribs=(CASE WHEN $5::TEXT != '' THEN $5::JSONB ELSE attribs END),
updated_at=NOW()
WHERE id = $1 RETURNING id
),
d AS (
DELETE FROM subscriber_lists WHERE subscriber_id = $1 AND list_id != ALL($6)
)
INSERT INTO subscriber_lists (subscriber_id, list_id)
VALUES( (SELECT id FROM s), UNNEST($6) )
ON CONFLICT (subscriber_id, list_id) DO NOTHING;
-- name: delete-subscribers
-- Delete one or more subscribers.
DELETE FROM subscribers WHERE id = ALL($1);
-- name: unsubscribe
-- Unsubscribes a subscriber given a campaign UUID (from all the lists in the campaign) and the subscriber UUID.
-- If $3 is TRUE, then all subscriptions of the subscriber is blacklisted
-- and all existing subscriptions, irrespective of lists, unsubscribed.
WITH lists AS (
-- SELECT (JSONB_ARRAY_ELEMENTS(lists)->>'id')::INT AS list_id FROM campaigns WHERE uuid = $1
SELECT * from campaigns where uuid=$1
),
sub AS (
UPDATE subscribers SET status = (CASE WHEN $3 IS TRUE THEN 'blacklisted' ELSE status END)
WHERE uuid = $2 RETURNING id
)
UPDATE subscriber_lists SET status = 'unsubscribed' WHERE
subscriber_id = (SELECT id FROM sub) AND
CASE WHEN $3 IS FALSE THEN list_id = ANY(SELECT list_id FROM lists) ELSE list_id != 0 END;
-- name: query-subscribers-into-lists
-- raw: true
-- Unprepared statement for issuring arbitrary WHERE conditions and getting
-- the resultant subscriber IDs into subscriber_lists.
WITH subs AS (
SELECT id FROM subscribers WHERE status != 'blacklisted' %s
)
INSERT INTO subscriber_lists (subscriber_id, list_id)
(SELECT id, UNNEST($1::INT[]) FROM subs)
ON CONFLICT (subscriber_id, list_id) DO NOTHING;
-- lists
-- name: get-lists
SELECT lists.*, COUNT(subscriber_lists.subscriber_id) AS subscriber_count
FROM lists LEFT JOIN subscriber_lists
ON (subscriber_lists.list_id = lists.id AND subscriber_lists.status != 'unsubscribed')
WHERE ($1 = 0 OR id = $1)
GROUP BY lists.id ORDER BY lists.created_at;
-- name: create-list
INSERT INTO lists (uuid, name, type, tags) VALUES($1, $2, $3, $4) RETURNING id;
-- name: update-list
UPDATE lists SET
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
type=(CASE WHEN $3 != '' THEN $3::list_type ELSE type END),
tags=(CASE WHEN ARRAY_LENGTH($4::VARCHAR(100)[], 1) > 0 THEN $4 ELSE tags END),
updated_at=NOW()
WHERE id = $1;
-- name: delete-lists
DELETE FROM lists WHERE id = ALL($1);
-- campaigns
-- name: create-campaign
-- This creates the campaign and inserts campaign_lists relationships.
WITH counts AS (
SELECT COUNT(id) as to_send, MAX(id) as max_sub_id
FROM subscribers
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
WHERE subscriber_lists.list_id=ANY($11::INT[])
AND subscribers.status='enabled'
),
camp AS (
INSERT INTO campaigns (uuid, name, subject, from_email, body, content_type, send_at, tags, messenger, template_id, to_send, max_subscriber_id)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
(SELECT to_send FROM counts),
(SELECT max_sub_id FROM counts)
WHERE (SELECT COALESCE(MAX(to_send), 0) FROM counts) > 0
RETURNING id
)
INSERT INTO campaign_lists (campaign_id, list_id, list_name)
(SELECT (SELECT id FROM camp), id, name FROM lists WHERE id=ANY($11::INT[]))
RETURNING (SELECT id FROM camp);
-- name: get-campaigns
-- Here, 'lists' is returned as an aggregated JSON array from campaign_lists because
-- the list reference may have been deleted.
SELECT campaigns.*, (
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
campaign_lists.list_name AS name
FROM campaign_lists WHERE campaign_lists.campaign_id = campaigns.id
) l
) AS lists
FROM campaigns
WHERE ($1 = 0 OR id = $1) AND status=(CASE WHEN $2 != '' THEN $2::campaign_status ELSE status END)
ORDER BY created_at DESC OFFSET $3 LIMIT $4;
-- name: get-campaign-for-preview
SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body
FROM campaigns
LEFT JOIN templates ON (templates.id = campaigns.template_id)
WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW()))
AND NOT(campaigns.id = ANY($1::INT[]))
-- name: get-campaign-stats
SELECT id, status, to_send, sent, started_at, updated_at
FROM campaigns
WHERE status=$1;
-- name: next-campaigns
-- Retreives campaigns that are running (or scheduled and the time's up) and need
-- to be processed. It updates the to_send count and max_subscriber_id of the campaign,
-- that is, the total number of subscribers to be processed across all lists of a campaign.
-- Thus, it has a sideaffect.
-- In addition, it finds the max_subscriber_id, the upper limit across all lists of
-- a campaign. This is used to fetch and slice subscribers for the campaign in next-subscriber-campaigns.
WITH camps AS (
-- Get all running campaigns and their template bodies (if the template's deleted, the default template body instead)
SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body
FROM campaigns
LEFT JOIN templates ON (templates.id = campaigns.template_id)
WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW()))
AND NOT(campaigns.id = ANY($1::INT[]))
),
counts AS (
-- For each campaign above, get the total number of subscribers and the max_subscriber_id across all its lists.
SELECT campaign_id, COUNT(subs.id) as to_send, COALESCE(MAX(subs.id), 0) as max_subscriber_id
FROM subscribers subs, subscriber_lists sublists, campaign_lists camplists
WHERE sublists.list_id = camplists.list_id AND
subs.id = sublists.subscriber_id
AND camplists.campaign_id = ANY(SELECT id FROM camps)
GROUP BY camplists.campaign_id
),
u AS (
-- For each campaign above, update the to_send count.
UPDATE campaigns AS ca
SET to_send = co.to_send, max_subscriber_id = co.max_subscriber_id
FROM (SELECT * FROM counts) co
WHERE ca.id = co.campaign_id AND ca.to_send != co.to_send
)
SELECT * FROM camps;
-- name: next-campaign-subscribers
-- Returns a batch of subscribers in a given campaign starting from the last checkpoint
-- (last_subscriber_id). Every fetch updates the checkpoint and the sent count, which means
-- every fetch returns a new batch of subscribers until all rows are exhausted.
WITH camp AS (
SELECT last_subscriber_id, max_subscriber_id
FROM campaigns
WHERE id=$1 AND status='running'
),
subs AS (
SELECT * FROM subscribers
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id AND subscriber_lists.status != 'unsubscribed')
WHERE subscriber_lists.list_id=ANY(
SELECT list_id FROM campaign_lists where campaign_id=$1 AND list_id IS NOT NULL
)
AND id > (SELECT last_subscriber_id FROM camp)
AND id <= (SELECT max_subscriber_id FROM camp)
ORDER BY id LIMIT $2
),
u AS (
UPDATE campaigns
SET last_subscriber_id=(SELECT MAX(id) FROM subs),
sent=sent + (SELECT COUNT(id) FROM subs),
updated_at=NOW()
WHERE (SELECT COUNT(id) FROM subs) > 0 AND id=$1
)
SELECT * FROM subs;
-- name: get-one-campaign-subscriber
SELECT * FROM subscribers
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id AND subscriber_lists.status != 'unsubscribed')
WHERE subscriber_lists.list_id=ANY(
SELECT list_id FROM campaign_lists where campaign_id=$1 AND list_id IS NOT NULL
)
LIMIT 1;
-- name: update-campaign
WITH camp AS (
UPDATE campaigns SET
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
subject=(CASE WHEN $3 != '' THEN $3 ELSE subject END),
from_email=(CASE WHEN $4 != '' THEN $4 ELSE from_email END),
body=(CASE WHEN $5 != '' THEN $5 ELSE body END),
content_type=(CASE WHEN $6 != '' THEN $6::content_type ELSE content_type END),
send_at=(CASE WHEN $7 != '' THEN $7::TIMESTAMP WITH TIME ZONE ELSE send_at END),
tags=(CASE WHEN ARRAY_LENGTH($8::VARCHAR(100)[], 1) > 0 THEN $8 ELSE tags END),
template_id=(CASE WHEN $9 != 0 THEN $9 ELSE template_id END),
updated_at=NOW()
WHERE id = $1 RETURNING id
),
-- Reset the relationships
d AS (
DELETE FROM campaign_lists WHERE campaign_id = $1
)
INSERT INTO campaign_lists (campaign_id, list_id, list_name)
(SELECT $1 as campaign_id, id, name FROM lists WHERE id=ANY($10::INT[]))
ON CONFLICT (campaign_id, list_id) DO UPDATE SET list_name = EXCLUDED.list_name;
-- name: update-campaign-counts
UPDATE campaigns SET
to_send=(CASE WHEN $2 != 0 THEN $2 ELSE to_send END),
sent=(CASE WHEN $3 != 0 THEN $3 ELSE sent END),
last_subscriber_id=(CASE WHEN $4 != 0 THEN $4 ELSE last_subscriber_id END),
updated_at=NOW()
WHERE id=$1;
-- name: update-campaign-status
UPDATE campaigns SET status=$2, updated_at=NOW() WHERE id = $1;
-- name: delete-campaign
DELETE FROM campaigns WHERE id=$1 AND (status = 'draft' OR status = 'scheduled');
-- users
-- name: get-users
SELECT * FROM users WHERE $1 = 0 OR id = $1 OFFSET $2 LIMIT $3;
-- name: create-user
INSERT INTO users (email, name, password, type, status) VALUES($1, $2, $3, $4, $5) RETURNING id;
-- name: update-user
UPDATE users SET
email=(CASE WHEN $2 != '' THEN $2 ELSE email END),
name=(CASE WHEN $3 != '' THEN $3 ELSE name END),
password=(CASE WHEN $4 != '' THEN $4 ELSE password END),
type=(CASE WHEN $5 != '' THEN $5::user_type ELSE type END),
status=(CASE WHEN $6 != '' THEN $6::user_status ELSE status END),
updated_at=NOW()
WHERE id = $1;
-- name: delete-user
-- Delete a user, except for the primordial super admin.
DELETE FROM users WHERE $1 != 1 AND id=$1;
-- templates
-- name: get-templates
-- Only if the second param ($2) is true, body is returned.
SELECT id, name, (CASE WHEN $2 = false THEN body ELSE '' END) as body,
is_default, created_at, updated_at
FROM templates WHERE $1 = 0 OR id = $1
ORDER BY created_at;
-- name: create-template
INSERT INTO templates (name, body) VALUES($1, $2) RETURNING id;
-- name: update-template
UPDATE templates SET
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
body=(CASE WHEN $3 != '' THEN $3 ELSE body END),
updated_at=NOW()
WHERE id = $1;
-- name: set-default-template
WITH u AS (
UPDATE templates SET is_default=true WHERE id=$1 RETURNING id
)
UPDATE templates SET is_default=false WHERE id != $1;
-- name: delete-template
-- Delete a template as long as there's more than one.
DELETE FROM templates WHERE id=$1 AND (SELECT COUNT(id) FROM templates) > 1 AND is_default = false;
-- media
-- name: insert-media
INSERT INTO media (uuid, filename, thumb, width, height, created_at) VALUES($1, $2, $3, $4, $5, NOW());
-- name: get-media
SELECT * FROM media ORDER BY created_at DESC;
-- name: delete-media
DELETE FROM media WHERE id=$1 RETURNING filename;
-- -- name: get-stats
-- WITH lists AS (
-- SELECT type, COUNT(id) AS num FROM lists GROUP BY type
-- ),
-- subs AS (
-- SELECT status, COUNT(id) AS num FROM subscribers GROUP by status
-- ),
-- orphans AS (
-- SELECT COUNT(id) FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
-- WHERE subscriber_lists.subscriber_id IS NULL
-- ),
-- camps AS (
-- SELECT status, COUNT(id) AS num FROM campaigns GROUP by status
-- )
-- SELECT JSON_BUILD_OBJECT('lists', lists);
-- row_to_json(t)
-- from (
-- select type, num from lists
-- ) t,

305
runner/runner.go Normal file
View File

@ -0,0 +1,305 @@
package runner
import (
"bytes"
"fmt"
"html/template"
"log"
"time"
"github.com/knadh/listmonk/messenger"
"github.com/knadh/listmonk/models"
)
const (
batchSize = 10000
// BaseTPL is the name of the base template.
BaseTPL = "base"
// ContentTpl is the name of the compiled message.
ContentTpl = "content"
)
// DataSource represents a data backend, such as a database,
// that provides subscriber and campaign records.
type DataSource interface {
NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error)
NextSubscribers(campID, limit int) ([]*models.Subscriber, error)
GetCampaign(campID int) (*models.Campaign, error)
PauseCampaign(campID int) error
CancelCampaign(campID int) error
FinishCampaign(campID int) error
}
// Runner handles the scheduling, processing, and queuing of campaigns
// and message pushes.
type Runner struct {
cfg Config
src DataSource
messengers map[string]messenger.Messenger
logger *log.Logger
// Campaigns that are currently running.
camps map[int]*models.Campaign
msgQueue chan Message
subFetchQueue chan *models.Campaign
}
// Message represents an active subscriber that's being processed.
type Message struct {
Campaign *models.Campaign
Subscriber *models.Subscriber
UnsubscribeURL string
body []byte
to string
}
// Config has parameters for configuring the runner.
type Config struct {
Concurrency int
UnsubscribeURL string
}
// New returns a new instance of Mailer.
func New(cfg Config, src DataSource, l *log.Logger) *Runner {
r := Runner{
cfg: cfg,
messengers: make(map[string]messenger.Messenger),
src: src,
camps: make(map[int]*models.Campaign, 0),
logger: l,
subFetchQueue: make(chan *models.Campaign, 100),
msgQueue: make(chan Message, cfg.Concurrency),
}
return &r
}
// AddMessenger adds a Messenger messaging backend to the runner process.
func (r *Runner) AddMessenger(msg messenger.Messenger) error {
id := msg.Name()
if _, ok := r.messengers[id]; ok {
return fmt.Errorf("messenger '%s' is already loaded", id)
}
r.messengers[id] = msg
return nil
}
// GetMessengerNames returns the list of registered messengers.
func (r *Runner) GetMessengerNames() []string {
var names []string
for n := range r.messengers {
names = append(names, n)
}
return names
}
// HasMessenger checks if a given messenger is registered.
func (r *Runner) HasMessenger(id string) bool {
_, ok := r.messengers[id]
return ok
}
// Run is a blocking function (and hence should be invoked as a goroutine)
// that scans the source db at regular intervals for pending campaigns,
// and queues them for processing. The process queue fetches batches of
// subscribers and pushes messages to them for each queued campaign
// until all subscribers are exhausted, at which point, a campaign is marked
// as "finished".
func (r *Runner) Run(tick time.Duration) {
var (
tScanCampaigns = time.NewTicker(tick)
)
for {
select {
// Fetch all 'running campaigns that aren't being processed.
case <-tScanCampaigns.C:
campaigns, err := r.src.NextCampaigns(r.getPendingCampaignIDs())
if err != nil {
r.logger.Printf("error fetching campaigns: %v", err)
return
}
for _, c := range campaigns {
if err := r.addCampaign(c); err != nil {
r.logger.Printf("error processing campaign (%s): %v", c.Name, err)
continue
}
r.logger.Printf("start processing campaign (%s)", c.Name)
r.subFetchQueue <- c
}
// Fetch next set of subscribers for the incoming campaign ID
// and process them.
case c := <-r.subFetchQueue:
has, err := r.nextSubscribers(c, batchSize)
if err != nil {
r.logger.Printf("error processing campaign batch (%s): %v", c.Name, err)
}
if has {
// There are more subscribers to fetch.
r.subFetchQueue <- c
} else {
// No subscribers.
if err := r.processExhaustedCampaign(c); err != nil {
r.logger.Printf("error processing campaign (%s): %v", c.Name, err)
}
}
}
}
}
// SpawnWorkers spawns workers goroutines that push out messages.
func (r *Runner) SpawnWorkers() {
for i := 0; i < r.cfg.Concurrency; i++ {
go func(ch chan Message) {
for {
select {
case m := <-ch:
r.messengers[m.Campaign.MessengerID].Push(
m.Campaign.FromEmail,
m.Subscriber.Email,
m.Campaign.Subject,
m.body)
}
}
}(r.msgQueue)
}
}
// addCampaign adds a campaign to the process queue.
func (r *Runner) addCampaign(c *models.Campaign) error {
var tplErr error
c.Tpl, tplErr = CompileMessageTemplate(c.TemplateBody, c.Body)
if tplErr != nil {
return tplErr
}
// Validate messenger.
if _, ok := r.messengers[c.MessengerID]; !ok {
r.src.CancelCampaign(c.ID)
return fmt.Errorf("unknown messenger %s on campaign %s", c.MessengerID, c.Name)
}
// Add the campaign to the active map.
r.camps[c.ID] = c
return nil
}
// getPendingCampaignIDs returns the IDs of campaigns currently being processed.
func (r *Runner) getPendingCampaignIDs() []int64 {
// Needs to return an empty slice in case there are no campaigns.
ids := make([]int64, 0)
for _, c := range r.camps {
ids = append(ids, int64(c.ID))
}
return ids
}
// nextSubscribers processes the next batch of subscribers in a given campaign.
// If returns a bool indicating whether there any subscribers were processed
// in the current batch or not. This can happen when all the subscribers
// have been processed, or if a campaign has been paused or cancelled abruptly.
func (r *Runner) nextSubscribers(c *models.Campaign, batchSize int) (bool, error) {
// Fetch a batch of subscribers.
subs, err := r.src.NextSubscribers(c.ID, batchSize)
if err != nil {
return false, fmt.Errorf("error fetching campaign subscribers (%s): %v", c.Name, err)
}
// There are no subscribers.
if len(subs) == 0 {
return false, nil
}
// Push messages.
for _, s := range subs {
to, body, err := r.makeMessage(c, s)
if err != nil {
r.logger.Printf("error preparing message (%s) (%s): %v", c.Name, s.Email, err)
continue
}
// Send the message.
r.msgQueue <- Message{Campaign: c,
Subscriber: s,
to: to,
body: body}
}
return true, nil
}
func (r *Runner) processExhaustedCampaign(c *models.Campaign) error {
cm, err := r.src.GetCampaign(c.ID)
if err != nil {
return err
}
// If a running campaign has exhausted subscribers, it's finished.
// Otherwise, it's paused or cancelled.
if cm.Status == models.CampaignStatusRunning {
if err := r.src.FinishCampaign(c.ID); err != nil {
r.logger.Printf("error finishing campaign (%s): %v", c.Name, err)
} else {
r.logger.Printf("campaign (%s) finished", c.Name)
}
} else {
r.logger.Printf("stop processing campaign (%s)", c.Name)
}
delete(r.camps, c.ID)
return nil
}
// makeMessage prepares a campaign message for a subscriber and returns
// the 'to' address and the body.
func (r *Runner) makeMessage(c *models.Campaign, s *models.Subscriber) (string, []byte, error) {
// Render the message body.
var (
out = bytes.Buffer{}
tplMsg = Message{Campaign: c,
Subscriber: s,
UnsubscribeURL: fmt.Sprintf(r.cfg.UnsubscribeURL, c.UUID, s.UUID)}
)
if err := c.Tpl.ExecuteTemplate(&out, BaseTPL, tplMsg); err != nil {
return "", nil, err
}
return s.Email, out.Bytes(), nil
}
// CompileMessageTemplate takes a base template body string and a child (message) template
// body string, compiles both and inserts the child template as the named template "content"
// and returns the resultant template.
func CompileMessageTemplate(baseBody, childBody string) (*template.Template, error) {
// Compile the base template.
baseTPL, err := template.New(BaseTPL).Parse(baseBody)
if err != nil {
return nil, fmt.Errorf("error compiling base template: %v", err)
}
// Compile the campaign message.
msgTpl, err := template.New(ContentTpl).Parse(childBody)
if err != nil {
return nil, fmt.Errorf("error compiling message: %v", err)
}
out, err := baseTPL.AddParseTree(ContentTpl, msgTpl.Tree)
if err != nil {
return nil, fmt.Errorf("error inserting child template: %v", err)
}
return out, nil
}

60
runner_db.go Normal file
View File

@ -0,0 +1,60 @@
package main
import (
"github.com/knadh/listmonk/models"
"github.com/lib/pq"
)
// runnerDB implements runner.DataSource over the primary
// database.
type runnerDB struct {
queries *Queries
}
func newRunnerDB(q *Queries) *runnerDB {
return &runnerDB{
queries: q,
}
}
// NextCampaigns retrieves active campaigns ready to be processed.
func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) {
var out []*models.Campaign
err := r.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs))
return out, err
}
// NextSubscribers retrieves a subset of subscribers of a given campaign.
// Since batches are processed sequentially, the retrieval is ordered by ID,
// and every batch takes the last ID of the last batch and fetches the next
// batch above that.
func (r *runnerDB) NextSubscribers(campID, limit int) ([]*models.Subscriber, error) {
var out []*models.Subscriber
err := r.queries.NextCampaignSubscribers.Select(&out, campID, limit)
return out, err
}
// GetCampaign fetches a campaign from the database.
func (r *runnerDB) GetCampaign(campID int) (*models.Campaign, error) {
var out = &models.Campaign{}
err := r.queries.GetCampaigns.Get(out, campID, "", 0, 1)
return out, err
}
// PauseCampaign marks a campaign as paused.
func (r *runnerDB) PauseCampaign(campID int) error {
_, err := r.queries.UpdateCampaignStatus.Exec(campID, models.CampaignStatusPaused)
return err
}
// CancelCampaign marks a campaign as cancelled.
func (r *runnerDB) CancelCampaign(campID int) error {
_, err := r.queries.UpdateCampaignStatus.Exec(campID, models.CampaignStatusCancelled)
return err
}
// FinishCampaign marks a campaign as finished.
func (r *runnerDB) FinishCampaign(campID int) error {
_, err := r.queries.UpdateCampaignStatus.Exec(campID, models.CampaignStatusFinished)
return err
}

169
schema.sql Normal file
View File

@ -0,0 +1,169 @@
DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('superadmin', 'user');
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
DROP TYPE IF EXISTS list_type CASCADE; CREATE TYPE list_type AS ENUM ('public', 'private', 'temporary');
DROP TYPE IF EXISTS subscriber_status CASCADE; CREATE TYPE subscriber_status AS ENUM ('enabled', 'disabled', 'blacklisted');
DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed');
DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain');
-- users
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password TEXT NOT NULL,
type user_type NOT NULL,
status user_status NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
DROP INDEX IF EXISTS idx_users_email; CREATE INDEX idx_users_email ON users(email);
-- subscribers
DROP TABLE IF EXISTS subscribers CASCADE;
CREATE TABLE subscribers (
id SERIAL PRIMARY KEY,
uuid uuid NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
attribs JSONB,
status subscriber_status NOT NULL,
campaigns INTEGER[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
DROP INDEX IF EXISTS idx_subscribers_email; CREATE INDEX idx_subscribers_email ON subscribers(email);
-- lists
DROP TABLE IF EXISTS lists CASCADE;
CREATE TABLE lists (
id SERIAL PRIMARY KEY,
uuid uuid NOT NULL UNIQUE,
name TEXT NOT NULL,
type list_type NOT NULL,
tags VARCHAR(100)[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
DROP INDEX IF EXISTS idx_lists_uuid; CREATE INDEX idx_lists_uuid ON lists(uuid);
DROP TABLE IF EXISTS subscriber_lists CASCADE;
CREATE TABLE subscriber_lists (
subscriber_id INTEGER REFERENCES subscribers(id) ON DELETE CASCADE ON UPDATE CASCADE,
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
status subscription_status NOT NULL DEFAULT 'unconfirmed',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY(subscriber_id, list_id)
);
-- templates
DROP TABLE IF EXISTS templates CASCADE;
CREATE TABLE templates (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
body TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX ON templates (is_default) WHERE is_default = true;
-- campaigns
DROP TABLE IF EXISTS campaigns CASCADE;
CREATE TABLE campaigns (
id SERIAL PRIMARY KEY,
uuid uuid NOT NULL UNIQUE,
name TEXT NOT NULL,
subject TEXT NOT NULL,
from_email TEXT NOT NULL,
body TEXT NOT NULL,
content_type content_type NOT NULL DEFAULT 'richtext',
send_at TIMESTAMP WITH TIME ZONE,
status campaign_status NOT NULL DEFAULT 'draft',
tags VARCHAR(100)[],
-- The ID of the messenger backend used to send this campaign.
messenger TEXT NOT NULL,
template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1,
-- The lists to which a campaign is sent can change at any point.
-- They can be deleted, or they could be ephmeral. Hence, storing
-- references to the lists table is not possible. The list names and
-- their erstwhile IDs are stored in a JSON blob for posterity.
lists JSONB,
-- Progress and stats.
to_send INT NOT NULL DEFAULT 0,
sent INT NOT NULL DEFAULT 0,
max_subscriber_id INT NOT NULL DEFAULT 0,
last_subscriber_id INT NOT NULL DEFAULT 0,
started_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
DROP INDEX IF EXISTS idx_campaigns_uuid; CREATE INDEX idx_campaigns_uuid ON campaigns(uuid);
DROP TABLE IF EXISTS campaign_lists CASCADE;
CREATE TABLE campaign_lists (
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
-- Lists may be deleted, so list_id is nullable
-- and a copy of the original list name is maintained here.
list_id INTEGER NULL REFERENCES lists(id) ON DELETE SET NULL ON UPDATE CASCADE,
list_name TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX ON campaign_lists (campaign_id, list_id);
DROP TABLE IF EXISTS campaign_views CASCADE;
CREATE TABLE campaign_views (
id SERIAL PRIMARY KEY,
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
-- Subscribers may be deleted, but the link counts should remain.
subscriber_id INTEGER NULL REFERENCES subscribers(id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- media
DROP TABLE IF EXISTS media CASCADE;
CREATE TABLE media (
id SERIAL PRIMARY KEY,
uuid uuid NOT NULL UNIQUE,
filename TEXT NOT NULL,
thumb TEXT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- links
DROP TABLE IF EXISTS links CASCADE;
CREATE TABLE links (
id SERIAL PRIMARY KEY,
uuid uuid NOT NULL UNIQUE,
url TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
DROP TABLE IF EXISTS link_clicks CASCADE;
CREATE TABLE link_clicks (
id SERIAL PRIMARY KEY,
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
link_id INTEGER NULL REFERENCES links(id) ON DELETE CASCADE ON UPDATE CASCADE,
-- Subscribers may be deleted, but the link counts should remain.
subscriber_id INTEGER NULL REFERENCES subscribers(id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

6
sqlboiler.toml Normal file
View File

@ -0,0 +1,6 @@
[postgres]
dbname="listmonk"
host="localhost"
port=5432
user="postgres"
pass="postgres"

528
subimporter/importer.go Normal file
View File

@ -0,0 +1,528 @@
// Package subimporter implements a bulk ZIP/CSV importer of subscribers.
// It implements a simple queue for buffering imports and committing records
// to DB along with ZIP and CSV handling utilities. It is meant to be used as
// a singleton as each Importer instance is stateful, where it keeps track of
// an import in progress. Only one import should happen on a single importer
// instance at a time.
package subimporter
import (
"archive/zip"
"bytes"
"database/sql"
"encoding/csv"
"encoding/json"
"errors"
"io"
"io/ioutil"
"log"
"os"
"strings"
"sync"
"github.com/lib/pq"
uuid "github.com/satori/go.uuid"
"github.com/asaskevich/govalidator"
"github.com/knadh/listmonk/models"
)
const (
// stdInputMaxLen is the maximum allowed length for a standard input field.
stdInputMaxLen = 200
// commitBatchSize is the number of inserts to commit in a single SQL transaction.
commitBatchSize = 10000
// SubscriberStatusEnabled indicates that a subscriber is active.
SubscriberStatusEnabled = "enabled"
// SubscriberStatusDisabled indicates that a subscriber is inactive or unsubscribed.
SubscriberStatusDisabled = "disabled"
// SubscriberStatusBlacklisted indicates that a subscriber is blacklisted.
SubscriberStatusBlacklisted = "blacklisted"
StatusNone = "none"
StatusImporting = "importing"
StatusStopping = "stopping"
StatusFinished = "finished"
StatusFailed = "failed"
)
// Importer represents the bulk CSV subscriber import system.
type Importer struct {
stmt *sql.Stmt
db *sql.DB
isImporting bool
stop chan bool
status *Status
sync.RWMutex
}
// Session represents a single import session.
type Session struct {
im *Importer
subQueue chan models.Subscriber
log *log.Logger
overrideStatus bool
listIDs []int
}
// Status reporesents statistics from an ongoing import session.
type Status struct {
Name string `json:"name"`
Total int `json:"total"`
Imported int `json:"imported"`
Status string `json:"status"`
logBuf *bytes.Buffer
}
// SubReq is a wrapper over the Subscriber model.
type SubReq struct {
models.Subscriber
Lists pq.Int64Array `json:"lists"`
}
var (
// ErrIsImporting is thrown when an import request is made while an
// import is already running.
ErrIsImporting = errors.New("import is already running")
csvHeaders = map[string]bool{"email": true,
"name": true,
"status": true,
"attributes": true}
)
// New returns a new instance of Importer.
func New(stmt *sql.Stmt, db *sql.DB) *Importer {
im := Importer{
stmt: stmt,
stop: make(chan bool, 1),
db: db,
status: &Status{Status: StatusNone, logBuf: bytes.NewBuffer(nil)},
}
return &im
}
// NewSession returns an new instance of Session. It takes the name
// of the uploaded file, but doesn't do anything with it but retains it for stats.
func (im *Importer) NewSession(fName string, overrideStatus bool, listIDs []int) (*Session, error) {
if im.getStatus() != StatusNone {
return nil, errors.New("an import is already running")
}
im.Lock()
im.status = &Status{Status: StatusImporting,
Name: fName,
logBuf: bytes.NewBuffer(nil)}
im.Unlock()
s := &Session{
im: im,
log: log.New(im.status.logBuf, "", log.Ldate|log.Ltime),
subQueue: make(chan models.Subscriber, commitBatchSize),
overrideStatus: overrideStatus,
listIDs: listIDs,
}
s.log.Printf("processing '%s'", fName)
return s, nil
}
// GetStats returns the global Stats of the importer.
func (im *Importer) GetStats() Status {
im.RLock()
defer im.RUnlock()
return Status{
Name: im.status.Name,
Status: im.status.Status,
Total: im.status.Total,
Imported: im.status.Imported,
}
}
// GetLogs returns the log entries of the last import session.
func (im *Importer) GetLogs() []byte {
im.RLock()
defer im.RUnlock()
if im.status.logBuf == nil {
return []byte{}
}
return im.status.logBuf.Bytes()
}
// setStatus sets the Importer's status.
func (im *Importer) setStatus(status string) {
im.Lock()
im.status.Status = status
im.Unlock()
}
// setStatus get's the Importer's status.
func (im *Importer) getStatus() string {
im.RLock()
status := im.status.Status
im.RUnlock()
return status
}
// incrementImportCount sets the Importer's "imported" counter.
func (im *Importer) incrementImportCount(n int) {
im.Lock()
im.status.Imported += n
im.Unlock()
}
// Start is a blocking function that selects on a channel queue until all
// subscriber entries in the import session are imported. It should be
// invoked as a goroutine.
func (s *Session) Start() {
var (
tx *sql.Tx
stmt *sql.Stmt
err error
total = 0
cur = 0
listIDs = make(pq.Int64Array, len(s.listIDs))
)
for i, v := range s.listIDs {
listIDs[i] = int64(v)
}
for sub := range s.subQueue {
if cur == 0 {
// New transaction batch.
tx, err = s.im.db.Begin()
if err != nil {
s.log.Printf("error creating DB transaction: %v", err)
continue
}
stmt = tx.Stmt(s.im.stmt)
}
_, err := stmt.Exec(
uuid.NewV4(),
sub.Email,
sub.Name,
sub.Status,
sub.Attribs,
s.overrideStatus,
listIDs)
if err != nil {
s.log.Printf("error executing insert: %v", err)
continue
}
cur++
total++
// Batch size is met. Commit.
if cur%commitBatchSize == 0 {
if err := tx.Commit(); err != nil {
tx.Rollback()
s.log.Printf("error committing to DB: %v", err)
} else {
s.im.incrementImportCount(cur)
s.log.Printf("imported %d", total)
}
cur = 0
}
}
// Queue's closed and there's nothing left to commit.
if cur == 0 {
s.im.setStatus(StatusFinished)
s.log.Printf("imported finished")
return
}
// Queue's closed and there are records left to commit.
if err := tx.Commit(); err != nil {
tx.Rollback()
s.im.setStatus(StatusFailed)
s.log.Printf("error committing to DB: %v", err)
return
}
s.im.incrementImportCount(cur)
s.im.setStatus(StatusFinished)
s.log.Printf("imported finished")
}
// ExtractZIP takes a ZIP file's path and extracts all .csv files in it to
// a temporary directory, and returns the name of the temp directory and the
// list of extracted .csv files.
func (s *Session) ExtractZIP(srcPath string, maxCSVs int) (string, []string, error) {
if s.im.isImporting {
return "", nil, ErrIsImporting
}
z, err := zip.OpenReader(srcPath)
if err != nil {
return "", nil, err
}
defer z.Close()
// Create a temporary directory to extract the files.
dir, err := ioutil.TempDir("", "listmonk")
if err != nil {
s.log.Printf("error creating temporary directory for extracting ZIP: %v", err)
return "", nil, err
}
var files []string
for _, f := range z.File {
fName := f.FileInfo().Name()
// Skip directories.
if f.FileInfo().IsDir() {
s.log.Printf("skipping directory '%s'", fName)
continue
}
// Skip files without the .csv extension.
if !strings.Contains(strings.ToLower(fName), "csv") {
s.log.Printf("skipping non .csv file '%s'", fName)
continue
}
s.log.Printf("extracting '%s'", fName)
src, err := f.Open()
if err != nil {
s.log.Printf("error opening '%s' from ZIP: '%v'", fName, err)
return "", nil, err
}
defer src.Close()
out, err := os.OpenFile(dir+"/"+fName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
s.log.Printf("error creating '%s/%s': '%v'", dir, fName, err)
return "", nil, err
}
defer out.Close()
if _, err := io.Copy(out, src); err != nil {
s.log.Printf("error extracting to '%s/%s': '%v'", dir, fName, err)
return "", nil, err
}
s.log.Printf("extracted '%s'", fName)
files = append(files, fName)
if len(files) > maxCSVs {
s.log.Printf("won't extract any more files. Maximum is %d", maxCSVs)
break
}
}
return dir, files, nil
}
// LoadCSV loads a CSV file and validates and imports the subscriber entries in it.
func (s *Session) LoadCSV(srcPath string, delim rune) error {
if s.im.isImporting {
return ErrIsImporting
}
// Default status is "failed" in case the function
// returns at one of the many possible errors.
failed := true
defer func() {
if failed {
s.im.setStatus(StatusFailed)
}
}()
f, err := os.Open(srcPath)
if err != nil {
return err
}
// Count the total number of lines in the file. This doesn't distinguish
// between "blank" and non "blank" lines, and is only used to derive
// the progress percentage for the frontend.
numLines, err := countLines(f)
if err != nil {
s.log.Printf("error counting lines in '%s': '%v'", srcPath, err)
return err
}
s.im.Lock()
s.im.status.Total = numLines
s.im.Unlock()
// Rewind, now that we've done a linecount on the same handler.
f.Seek(0, 0)
rd := csv.NewReader(f)
rd.Comma = delim
// Read the header.
csvHdr, err := rd.Read()
if err != nil {
s.log.Printf("error reading header from '%s': '%v'", srcPath, err)
return err
}
hdrKeys := s.mapCSVHeaders(csvHdr, csvHeaders)
// email, and name are required headers.
if _, ok := hdrKeys["email"]; !ok {
s.log.Printf("'email' column not found in '%s'", srcPath)
return errors.New("'email' column not found")
}
if _, ok := hdrKeys["name"]; !ok {
s.log.Printf("'name' column not found in '%s'", srcPath)
return errors.New("'name' column not found")
}
var (
lnHdr = len(hdrKeys)
i = 1
)
for {
// Check for the stop signal.
select {
case <-s.im.stop:
failed = false
close(s.subQueue)
s.log.Println("stop request received")
return nil
default:
}
cols, err := rd.Read()
if err == io.EOF {
break
} else if err != nil {
s.log.Printf("error reading CSV '%s'", err)
return err
}
lnCols := len(cols)
if lnCols < lnHdr {
s.log.Printf("skipping line %d. column count (%d) does not match minimum header count (%d)", i, lnCols, lnHdr)
continue
}
// Iterate the key map and based on the indices mapped earlier,
// form a map of key: csv_value, eg: email: user@user.com.
row := make(map[string]string, lnCols)
for key := range csvHeaders {
row[key] = cols[hdrKeys[key]]
}
// Lowercase to ensure uniqueness in the DB.
row["email"] = strings.ToLower(strings.TrimSpace(row["email"]))
sub := models.Subscriber{
Email: row["email"],
Name: row["name"],
Status: row["status"],
}
// JSON attributes.
if len(row["attributes"]) > 0 {
var (
attribs models.SubscriberAttribs
b = []byte(row["attributes"])
)
if err := json.Unmarshal(b, &attribs); err != nil {
s.log.Printf("skipping invalid attributes JSON on line %d for '%s': %v", i, sub.Email, err)
} else {
sub.Attribs = attribs
}
}
// Send the subscriber to the queue.
s.subQueue <- sub
i++
}
close(s.subQueue)
failed = false
return nil
}
// Stop sends a signal to stop all existing imports.
func (im *Importer) Stop() {
if im.getStatus() != StatusImporting {
im.Lock()
im.status = &Status{Status: StatusNone}
im.Unlock()
return
}
select {
case im.stop <- true:
im.setStatus(StatusStopping)
default:
}
}
// mapCSVHeaders takes a list of headers obtained from a CSV file, a map of known headers,
// and returns a new map with each of the headers in the known map mapped by the position (0-n)
// in the given CSV list.
func (s *Session) mapCSVHeaders(csvHdrs []string, knownHdrs map[string]bool) map[string]int {
// Map 0-n column index to the header keys, name: 0, email: 1 etc.
// This is to allow dynamic ordering of columns in th CSV.
hdrKeys := make(map[string]int)
for i, h := range csvHdrs {
if _, ok := knownHdrs[h]; !ok {
s.log.Printf("ignoring unknown header '%s'", h)
continue
}
hdrKeys[h] = i
}
return hdrKeys
}
// ValidateFields validates incoming subscriber field values.
func ValidateFields(s SubReq) error {
if !govalidator.IsEmail(s.Email) {
return errors.New("invalid `email`")
}
if !govalidator.IsByteLength(s.Name, 1, stdInputMaxLen) {
return errors.New("invalid length for `name`")
}
if s.Status != SubscriberStatusEnabled && s.Status != SubscriberStatusDisabled &&
s.Status != SubscriberStatusBlacklisted {
return errors.New("invalid `status`")
}
return nil
}
// countLines counts the number of line breaks in a file. This does not
// distinguish between "blank" and non "blank" lines.
// Credit: https://stackoverflow.com/a/24563853
func countLines(r io.Reader) (int, error) {
var (
buf = make([]byte, 32*1024)
count = 0
lineSep = []byte{'\n'}
)
for {
c, err := r.Read(buf)
count += bytes.Count(buf[:c], lineSep)
switch {
case err == io.EOF:
return count, nil
case err != nil:
return count, err
}
}
}

341
subscribers.go Normal file
View File

@ -0,0 +1,341 @@
package main
// !!!!!!!!!!! TODO
// For non-flat JSON attribs, show the advanced editor instead of the key-value editor
import (
"context"
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/asaskevich/govalidator"
"github.com/knadh/listmonk/models"
"github.com/knadh/listmonk/subimporter"
"github.com/labstack/echo"
"github.com/lib/pq"
uuid "github.com/satori/go.uuid"
)
type subsWrap struct {
Results models.Subscribers `json:"results"`
Query string `json:"query"`
Total int `json:"total"`
PerPage int `json:"per_page"`
Page int `json:"page"`
}
type queryAddResp struct {
Count int64 `json:"count"`
}
type queryAddReq struct {
Query string `json:"query"`
SourceList int `json:"source_list"`
TargetLists pq.Int64Array `json:"target_lists"`
}
var jsonMap = []byte("{}")
// handleGetSubscriber handles the retrieval of a single subscriber by ID.
func handleGetSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
out models.Subscribers
id, _ = strconv.Atoi(c.Param("id"))
)
// Fetch one list.
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.")
}
err := app.Queries.GetSubscriber.Select(&out, id, nil)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
} else if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.")
}
out.LoadLists(app.Queries.GetSubscriberLists)
return c.JSON(http.StatusOK, okResp{out[0]})
}
// handleQuerySubscribers handles querying subscribers based on arbitrary conditions in SQL.
func handleQuerySubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams())
// Limit the subscribers to a particular list?
listID, _ = strconv.Atoi(c.FormValue("list_id"))
hasList bool
// The "WHERE ?" bit.
query = c.FormValue("query")
out subsWrap
)
if listID < 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `list_id`.")
} else if listID > 0 {
hasList = true
}
// There's an arbitrary query condition from the frontend.
cond := ""
if query != "" {
cond = " AND " + query
}
// The SQL queries to be executed are different for global subscribers
// and subscribers belonging to a specific list.
var (
stmt = ""
stmtCount = ""
)
if hasList {
stmt = fmt.Sprintf(app.Queries.QuerySubscribersByList,
listID, cond, pg.Offset, pg.Limit)
stmtCount = fmt.Sprintf(app.Queries.QuerySubscribersByListCount,
listID, cond)
} else {
stmt = fmt.Sprintf(app.Queries.QuerySubscribers,
cond, pg.Offset, pg.Limit)
stmtCount = fmt.Sprintf(app.Queries.QuerySubscribersCount, cond)
}
// Create a readonly transaction to prevent mutations.
tx, err := app.DB.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error preparing query: %v", pqErrMsg(err)))
}
// Run the actual query.
if err := tx.Select(&out.Results, stmt); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error querying subscribers: %v", pqErrMsg(err)))
}
// Run the query count.
if err := tx.Get(&out.Total, stmtCount); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error running count query: %v", pqErrMsg(err)))
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error in subscriber query transaction: %v", pqErrMsg(err)))
}
// Lazy load lists for each subscriber.
if err := out.Results.LoadLists(app.Queries.GetSubscriberLists); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err)))
}
if len(out.Results) == 0 {
out.Results = make(models.Subscribers, 0)
return c.JSON(http.StatusOK, okResp{out})
}
// Meta.
out.Query = query
out.Page = pg.Page
out.PerPage = pg.PerPage
return c.JSON(http.StatusOK, okResp{out})
}
// handleCreateSubscriber handles subscriber creation.
func handleCreateSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
req subimporter.SubReq
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
} else if err := subimporter.ValidateFields(req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Insert and read ID.
var newID int
err := app.Queries.UpsertSubscriber.Get(&newID,
uuid.NewV4(),
req.Email,
req.Name,
req.Status,
req.Attribs,
true,
req.Lists)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating subscriber: %v", err))
}
// Hand over to the GET handler to return the last insertion.
c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", newID))
return c.JSON(http.StatusOK, handleGetSubscriber(c))
}
// handleUpdateSubscriber handles subscriber modification.
func handleUpdateSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
req subimporter.SubReq
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
if req.Email != "" && !govalidator.IsEmail(req.Email) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `email`.")
}
if req.Name != "" && !govalidator.IsByteLength(req.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid length for `name`.")
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
_, err := app.Queries.UpdateSubscriber.Exec(req.ID,
req.Email,
req.Name,
req.Status,
req.Attribs,
req.Lists)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Update failed: %v", pqErrMsg(err)))
}
return handleGetSubscriber(c)
}
// handleDeleteSubscribers handles subscriber deletion,
// either a single one (ID in the URI), or a list.
func handleDeleteSubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
ids pq.Int64Array
)
// Read the list IDs if they were sent in the body.
c.Bind(&ids)
if id < 1 && len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
if id > 0 {
ids = append(ids, id)
}
if _, err := app.Queries.DeleteSubscribers.Exec(ids); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Delete failed: %v", err))
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleQuerySubscribersIntoLists handles querying subscribers based on arbitrary conditions in SQL
// and adding them to given lists.
func handleQuerySubscribersIntoLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
req queryAddReq
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error parsing request: %v", err))
}
if len(req.TargetLists) < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `target_lists`.")
}
if req.SourceList < 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `source_list`.")
}
if req.Query == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber `query`.")
}
cond := " AND " + req.Query
// The SQL queries to be executed are different for global subscribers
// and subscribers belonging to a specific list.
var (
stmt = ""
stmtDry = ""
)
if req.SourceList > 0 {
stmt = fmt.Sprintf(app.Queries.QuerySubscribersByList, req.SourceList, cond)
stmtDry = fmt.Sprintf(app.Queries.QuerySubscribersByList, req.SourceList, cond, 0, 1)
} else {
stmt = fmt.Sprintf(app.Queries.QuerySubscribersIntoLists, cond)
stmtDry = fmt.Sprintf(app.Queries.QuerySubscribers, cond, 0, 1)
}
// Create a readonly transaction to prevent mutations.
// This is used to dry-run the arbitrary query before it's used to
// insert subscriptions.
tx, err := app.DB.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error preparing query (dry-run): %v", pqErrMsg(err)))
}
// Perform the dry run.
if _, err := tx.Exec(stmtDry); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error querying (dry-run) subscribers: %v", pqErrMsg(err)))
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error in subscriber dry-run query transaction: %v", pqErrMsg(err)))
}
// Prepare the query.
q, err := app.DB.Preparex(stmt)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error preparing query: %v", pqErrMsg(err)))
}
// Run the query.
res, err := q.Exec(req.TargetLists)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error adding subscribers to lists: %v", pqErrMsg(err)))
}
num, _ := res.RowsAffected()
return c.JSON(http.StatusOK, okResp{queryAddResp{num}})
}

226
templates.go Normal file
View File

@ -0,0 +1,226 @@
package main
import (
"bytes"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/asaskevich/govalidator"
"github.com/knadh/listmonk/models"
"github.com/knadh/listmonk/runner"
"github.com/labstack/echo"
)
const dummyTpl = `
<p>Hi there</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna. Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat. Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.</p>
<h3>Sub heading</h3>
<p>Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod. Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.</p>
<p>Here is a link to <a href="https://listmonk.app" target="_blank">listmonk</a>.</p>
`
type dummyMessage struct {
UnsubscribeURL string
}
// handleGetTemplates handles retrieval of templates.
func handleGetTemplates(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []models.Template
id, _ = strconv.Atoi(c.Param("id"))
single = false
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
// Fetch one list.
if id > 0 {
single = true
}
err := app.Queries.GetTemplates.Select(&out, id, noBody)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
} else if single && len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
} else if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
if single {
return c.JSON(http.StatusOK, okResp{out[0]})
}
return c.JSON(http.StatusOK, okResp{out})
}
// handlePreviewTemplate renders the HTML preview of a template.
func handlePreviewTemplate(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
tpls []models.Template
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
err := app.Queries.GetTemplates.Select(&tpls, id, false)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
}
if len(tpls) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
}
t := tpls[0]
// Compile the template.
tpl, err := runner.CompileMessageTemplate(t.Body, dummyTpl)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err))
}
// Render the message body.
var out = bytes.Buffer{}
if err := tpl.ExecuteTemplate(&out,
runner.BaseTPL,
dummyMessage{UnsubscribeURL: "#dummy"}); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error executing template: %v", err))
}
return c.HTML(http.StatusOK, out.String())
}
// handleCreateTemplate handles template creation.
func handleCreateTemplate(c echo.Context) error {
var (
app = c.Get("app").(*App)
o = models.Template{}
)
if err := c.Bind(&o); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if err := validateTemplate(o); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// Insert and read ID.
var newID int
if err := app.Queries.CreateTemplate.Get(&newID,
o.Name,
o.Body); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error template user: %v", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", newID))
return c.JSON(http.StatusOK, handleGetLists(c))
}
// handleUpdateTemplate handles template modification.
func handleUpdateTemplate(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
var o models.Template
if err := c.Bind(&o); err != nil {
return err
}
if err := validateTemplate(o); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// TODO: PASSWORD HASHING.
res, err := app.Queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating template: %s", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
}
return handleGetTemplates(c)
}
// handleTemplateSetDefault handles template modification.
func handleTemplateSetDefault(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
_, err := app.Queries.SetDefaultTemplate.Exec(id)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating template: %s", pqErrMsg(err)))
}
return handleGetTemplates(c)
}
// handleDeleteTemplate handles template deletion.
func handleDeleteTemplate(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
} else if id == 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Cannot delete the primordial template.")
}
res, err := app.Queries.DeleteTemplate.Exec(id)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error deleting template: %v", err))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
"Cannot delete the last, default, or non-existent template.")
}
return c.JSON(http.StatusOK, okResp{true})
}
// validateTemplate validates template fields.
func validateTemplate(o models.Template) error {
if !govalidator.IsByteLength(o.Name, 1, stdInputMaxLen) {
return errors.New("invalid length for `name`")
}
if !strings.Contains(o.Body, `{{ template "content" . }}`) {
return errors.New(`template body should contain the {{ template "content" . }} placeholder`)
}
return nil
}

138
users.go Normal file
View File

@ -0,0 +1,138 @@
package main
import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/asaskevich/govalidator"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo"
)
// handleGetUsers handles retrieval of users.
func handleGetUsers(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []models.User
id, _ = strconv.Atoi(c.Param("id"))
single = false
)
// Fetch one list.
if id > 0 {
single = true
}
err := app.Queries.GetUsers.Select(&out, id)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching users: %s", pqErrMsg(err)))
} else if single && len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "User not found.")
} else if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
if single {
return c.JSON(http.StatusOK, okResp{out[0]})
}
return c.JSON(http.StatusOK, okResp{out})
}
// handleCreateUser handles user creation.
func handleCreateUser(c echo.Context) error {
var (
app = c.Get("app").(*App)
o = models.User{}
)
if err := c.Bind(&o); err != nil {
return err
}
if !govalidator.IsEmail(o.Email) {
return errors.New("invalid `email`")
}
if !govalidator.IsByteLength(o.Name, 1, stdInputMaxLen) {
return errors.New("invalid length for `name`")
}
// Insert and read ID.
var newID int
if err := app.Queries.CreateUser.Get(&newID,
o.Email,
o.Name,
o.Password,
o.Type,
o.Status); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating user: %v", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", newID))
return c.JSON(http.StatusOK, handleGetLists(c))
}
// handleUpdateUser handles user modification.
func handleUpdateUser(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
} else if id == 1 {
return echo.NewHTTPError(http.StatusBadRequest,
"The primordial super admin cannot be deleted.")
}
var o models.User
if err := c.Bind(&o); err != nil {
return err
}
if !govalidator.IsEmail(o.Email) {
return errors.New("invalid `email`")
}
if !govalidator.IsByteLength(o.Name, 1, stdInputMaxLen) {
return errors.New("invalid length for `name`")
}
// TODO: PASSWORD HASHING.
res, err := app.Queries.UpdateUser.Exec(o.ID,
o.Email,
o.Name,
o.Password,
o.Type,
o.Status)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating user: %s", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "User not found.")
}
return handleGetUsers(c)
}
// handleDeleteUser handles user deletion.
func handleDeleteUser(c echo.Context) error {
var (
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
return c.JSON(http.StatusOK, okResp{true})
}

247
utils.go Normal file
View File

@ -0,0 +1,247 @@
package main
import (
"bytes"
"crypto/rand"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
"github.com/knadh/goyesql"
"github.com/labstack/echo"
"github.com/lib/pq"
)
const tmpFilePrefix = "listmonk"
var (
// This matches filenames, sans extensions, of the format
// filename_(number). The number is incremented in case
// new file uploads conflict with existing filenames
// on the filesystem.
fnameRegexp, _ = regexp.Compile(`(.+?)_([0-9]+)$`)
// This replaces all special characters
tagRegexp, _ = regexp.Compile(`[^a-z0-9\-\s]`)
tagRegexpSpaces, _ = regexp.Compile(`[\s]+`)
)
// ScanToStruct prepares a given set of Queries and assigns the resulting
// *sql.Stmt statements to the fields of a given struct, matching based on the name
// in the `query` tag in the struct field names.
func scanQueriesToStruct(obj interface{}, q goyesql.Queries, db *sqlx.DB) error {
ob := reflect.ValueOf(obj)
if ob.Kind() == reflect.Ptr {
ob = ob.Elem()
}
if ob.Kind() != reflect.Struct {
return fmt.Errorf("Failed to apply SQL statements to struct. Non struct type: %T", ob)
}
// Go through every field in the struct and look for it in the Args map.
for i := 0; i < ob.NumField(); i++ {
f := ob.Field(i)
if f.IsValid() {
if tag := ob.Type().Field(i).Tag.Get("query"); tag != "" && tag != "-" {
// Extract the value of the `query` tag.
var (
tg = strings.Split(tag, ",")
name string
)
if len(tg) == 2 {
if tg[0] != "-" && tg[0] != "" {
name = tg[0]
}
} else {
name = tg[0]
}
// Query name found in the field tag is not in the map.
if _, ok := q[name]; !ok {
return fmt.Errorf("query '%s' not found in query map", name)
}
if !f.CanSet() {
return fmt.Errorf("query field '%s' is unexported", ob.Type().Field(i).Name)
}
switch f.Type().String() {
case "string":
// Unprepared SQL query.
f.Set(reflect.ValueOf(q[name].Query))
case "*sqlx.Stmt":
// Prepared query.
stmt, err := db.Preparex(q[name].Query)
if err != nil {
return fmt.Errorf("Error preparing query '%s': %v", name, err)
}
f.Set(reflect.ValueOf(stmt))
}
}
}
}
return nil
}
// uploadFile is a helper function on top of echo.Context for processing file uploads.
// It allows copying a single file given the incoming file field name.
// If the upload directory dir is empty, the file is copied to the system's temp directory.
// If name is empty, the incoming file's name along with a small random hash is used.
// When a slice of MIME types is given, the uploaded file's MIME type is validated against the list.
func uploadFile(key string, dir, name string, mimes []string, c echo.Context) (string, error) {
file, err := c.FormFile(key)
if err != nil {
return "", echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Invalid file uploaded: %v", err))
}
// Check MIME type.
if len(mimes) > 0 {
var (
typ = file.Header.Get("Content-type")
ok = false
)
for _, m := range mimes {
if typ == m {
ok = true
break
}
}
if !ok {
return "", echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Unsupported file type (%s) uploaded.", typ))
}
}
src, err := file.Open()
if err != nil {
return "", err
}
defer src.Close()
// There's no upload directory. Use a tempfile.
var out *os.File
if dir == "" {
o, err := ioutil.TempFile("", tmpFilePrefix)
if err != nil {
return "", echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error copying uploaded file: %v", err))
}
out = o
name = o.Name()
} else {
// There's no explicit name. Use the one posted in the HTTP request.
if name == "" {
name = strings.TrimSpace(file.Filename)
if name == "" {
name, _ = generateRandomString(10)
}
}
name = assertUniqueFilename(dir, name)
o, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664)
if err != nil {
return "", echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error copying uploaded file: %v", err))
}
out = o
}
defer out.Close()
if _, err = io.Copy(out, src); err != nil {
return "", echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error copying uploaded file: %v", err))
}
return name, nil
}
// Given an error, pqErrMsg will try to return pq error details
// if it's a pq error.
func pqErrMsg(err error) string {
if err, ok := err.(*pq.Error); ok {
if err.Detail != "" {
return fmt.Sprintf("%s. %s", err, err.Detail)
}
}
return err.Error()
}
// generateRandomString generates a cryptographically random, alphanumeric string of length n.
func generateRandomString(n int) (string, error) {
const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var bytes = make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
for k, v := range bytes {
bytes[k] = dictionary[v%byte(len(dictionary))]
}
return string(bytes), nil
}
// assertUniqueFilename takes a file path and check if it exists on the disk. If it doesn't,
// it returns the same name and if it does, it adds a small random hash to the filename
// and returns that.
func assertUniqueFilename(dir, fileName string) string {
var (
ext = filepath.Ext(fileName)
base = fileName[0 : len(fileName)-len(ext)]
num = 0
)
for {
// There's no name conflict.
if _, err := os.Stat(filepath.Join(dir, fileName)); os.IsNotExist(err) {
return fileName
}
// Does the name match the _(num) syntax?
r := fnameRegexp.FindAllStringSubmatch(fileName, -1)
if len(r) == 1 && len(r[0]) == 3 {
num, _ = strconv.Atoi(r[0][2])
}
num++
fileName = fmt.Sprintf("%s_%d%s", base, num, ext)
}
}
// normalizeTags takes a list of string tags and normalizes them by
// lowercasing and removing all special characters except for dashes.
func normalizeTags(tags []string) []string {
var (
out []string
space = []byte(" ")
dash = []byte("-")
)
for _, t := range tags {
rep := bytes.TrimSpace(tagRegexp.ReplaceAll(bytes.ToLower([]byte(t)), space))
rep = tagRegexpSpaces.ReplaceAll(rep, dash)
if len(rep) > 0 {
out = append(out, string(rep))
}
}
return out
}