Merge branch 'plaintext' into i18n
This commit is contained in:
commit
27d9eab4a2
|
@ -14,6 +14,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/jaytaylor/html2text"
|
||||||
"github.com/knadh/listmonk/internal/messenger"
|
"github.com/knadh/listmonk/internal/messenger"
|
||||||
"github.com/knadh/listmonk/internal/subimporter"
|
"github.com/knadh/listmonk/internal/subimporter"
|
||||||
"github.com/knadh/listmonk/models"
|
"github.com/knadh/listmonk/models"
|
||||||
|
@ -149,7 +150,7 @@ func handleGetCampaigns(c echo.Context) error {
|
||||||
return c.JSON(http.StatusOK, okResp{out})
|
return c.JSON(http.StatusOK, okResp{out})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePreviewTemplate renders the HTML preview of a campaign body.
|
// handlePreviewCampaign renders the HTML preview of a campaign body.
|
||||||
func handlePreviewCampaign(c echo.Context) error {
|
func handlePreviewCampaign(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
app = c.Get("app").(*App)
|
app = c.Get("app").(*App)
|
||||||
|
@ -212,6 +213,17 @@ func handlePreviewCampaign(c echo.Context) error {
|
||||||
return c.HTML(http.StatusOK, string(m.Body()))
|
return c.HTML(http.StatusOK, string(m.Body()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCampainBodyToText converts an HTML campaign body to plaintext.
|
||||||
|
func handleCampainBodyToText(c echo.Context) error {
|
||||||
|
out, err := html2text.FromString(c.FormValue("body"),
|
||||||
|
html2text.Options{PrettyTables: false})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.HTML(http.StatusOK, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
// handleCreateCampaign handles campaign creation.
|
// handleCreateCampaign handles campaign creation.
|
||||||
// Newly created campaigns are always drafts.
|
// Newly created campaigns are always drafts.
|
||||||
func handleCreateCampaign(c echo.Context) error {
|
func handleCreateCampaign(c echo.Context) error {
|
||||||
|
@ -256,6 +268,7 @@ func handleCreateCampaign(c echo.Context) error {
|
||||||
o.Subject,
|
o.Subject,
|
||||||
o.FromEmail,
|
o.FromEmail,
|
||||||
o.Body,
|
o.Body,
|
||||||
|
o.AltBody,
|
||||||
o.ContentType,
|
o.ContentType,
|
||||||
o.SendAt,
|
o.SendAt,
|
||||||
pq.StringArray(normalizeTags(o.Tags)),
|
pq.StringArray(normalizeTags(o.Tags)),
|
||||||
|
@ -309,8 +322,10 @@ func handleUpdateCampaign(c echo.Context) error {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.cantUpdate"))
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.cantUpdate"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incoming params.
|
// Read the incoming params into the existing campaign fields from the DB.
|
||||||
var o campaignReq
|
// This allows updating of values that have been sent where as fields
|
||||||
|
// that are not in the request retain the old values.
|
||||||
|
o := campaignReq{Campaign: cm}
|
||||||
if err := c.Bind(&o); err != nil {
|
if err := c.Bind(&o); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -326,6 +341,7 @@ func handleUpdateCampaign(c echo.Context) error {
|
||||||
o.Subject,
|
o.Subject,
|
||||||
o.FromEmail,
|
o.FromEmail,
|
||||||
o.Body,
|
o.Body,
|
||||||
|
o.AltBody,
|
||||||
o.ContentType,
|
o.ContentType,
|
||||||
o.SendAt,
|
o.SendAt,
|
||||||
o.SendLater,
|
o.SendLater,
|
||||||
|
@ -557,11 +573,12 @@ func handleTestCampaign(c echo.Context) error {
|
||||||
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override certain values in the DB with incoming values.
|
// Override certain values from the DB with incoming values.
|
||||||
camp.Name = req.Name
|
camp.Name = req.Name
|
||||||
camp.Subject = req.Subject
|
camp.Subject = req.Subject
|
||||||
camp.FromEmail = req.FromEmail
|
camp.FromEmail = req.FromEmail
|
||||||
camp.Body = req.Body
|
camp.Body = req.Body
|
||||||
|
camp.AltBody = req.AltBody
|
||||||
camp.Messenger = req.Messenger
|
camp.Messenger = req.Messenger
|
||||||
camp.ContentType = req.ContentType
|
camp.ContentType = req.ContentType
|
||||||
camp.TemplateID = req.TemplateID
|
camp.TemplateID = req.TemplateID
|
||||||
|
@ -601,6 +618,7 @@ func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) err
|
||||||
Subject: m.Subject(),
|
Subject: m.Subject(),
|
||||||
ContentType: camp.ContentType,
|
ContentType: camp.ContentType,
|
||||||
Body: m.Body(),
|
Body: m.Body(),
|
||||||
|
AltBody: m.AltBody(),
|
||||||
Subscriber: sub,
|
Subscriber: sub,
|
||||||
Campaign: camp,
|
Campaign: camp,
|
||||||
})
|
})
|
||||||
|
|
|
@ -91,6 +91,7 @@ func registerHTTPHandlers(e *echo.Echo) {
|
||||||
g.GET("/api/campaigns/:id", handleGetCampaigns)
|
g.GET("/api/campaigns/:id", handleGetCampaigns)
|
||||||
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||||
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||||
|
g.POST("/api/campaigns/:id/text", handlePreviewCampaign)
|
||||||
g.POST("/api/campaigns/:id/test", handleTestCampaign)
|
g.POST("/api/campaigns/:id/test", handleTestCampaign)
|
||||||
g.POST("/api/campaigns", handleCreateCampaign)
|
g.POST("/api/campaigns", handleCreateCampaign)
|
||||||
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
|
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
|
||||||
|
|
|
@ -120,6 +120,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
|
||||||
"No Reply <noreply@yoursite.com>",
|
"No Reply <noreply@yoursite.com>",
|
||||||
`<h3>Hi {{ .Subscriber.FirstName }}!</h3>
|
`<h3>Hi {{ .Subscriber.FirstName }}!</h3>
|
||||||
This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.`,
|
This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.`,
|
||||||
|
nil,
|
||||||
"richtext",
|
"richtext",
|
||||||
nil,
|
nil,
|
||||||
pq.StringArray{"test-campaign"},
|
pq.StringArray{"test-campaign"},
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"quill": "^1.3.7",
|
"quill": "^1.3.7",
|
||||||
"quill-delta": "^4.2.2",
|
"quill-delta": "^4.2.2",
|
||||||
"sass-loader": "^8.0.2",
|
"sass-loader": "^8.0.2",
|
||||||
|
"textversionjs": "^1.1.3",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
"vue-c3": "^1.2.11",
|
"vue-c3": "^1.2.11",
|
||||||
"vue-i18n": "^8.22.2",
|
"vue-i18n": "^8.22.2",
|
||||||
|
|
|
@ -224,6 +224,16 @@ section {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.plain-editor textarea {
|
||||||
|
height: 65vh;
|
||||||
|
}
|
||||||
|
.alt-body textarea {
|
||||||
|
height: 30vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* Table colors and padding */
|
/* Table colors and padding */
|
||||||
.main table {
|
.main table {
|
||||||
thead th {
|
thead th {
|
||||||
|
|
|
@ -138,16 +138,29 @@
|
||||||
</b-tab-item><!-- campaign -->
|
</b-tab-item><!-- campaign -->
|
||||||
|
|
||||||
<b-tab-item :label="$t('campaigns.content')" icon="text" :disabled="isNew">
|
<b-tab-item :label="$t('campaigns.content')" icon="text" :disabled="isNew">
|
||||||
<section class="wrap">
|
<editor
|
||||||
<editor
|
v-model="form.content"
|
||||||
v-model="form.content"
|
:id="data.id"
|
||||||
:id="data.id"
|
:title="data.name"
|
||||||
:title="data.name"
|
:contentType="data.contentType"
|
||||||
:contentType="data.contentType"
|
:body="data.body"
|
||||||
:body="data.body"
|
:disabled="!canEdit"
|
||||||
:disabled="!canEdit"
|
/>
|
||||||
/>
|
|
||||||
</section>
|
<div v-if="canEdit && form.content.contentType !== 'plain'" class="alt-body">
|
||||||
|
<p class="is-size-6 has-text-grey has-text-right">
|
||||||
|
<a v-if="form.altbody === null" href="#" @click.prevent="addAltBody">
|
||||||
|
<b-icon icon="text" size="is-small" /> {{ $t('campaigns.addAltText') }}
|
||||||
|
</a>
|
||||||
|
<a v-else href="#" @click.prevent="$utils.confirm(null, removeAltBody)">
|
||||||
|
<b-icon icon="trash-can-outline" size="is-small" />
|
||||||
|
{{ $t('campaigns.removeAltText') }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<b-input v-if="form.altbody !== null" v-model="form.altbody"
|
||||||
|
type="textarea" :disabled="!canEdit" />
|
||||||
|
</div>
|
||||||
</b-tab-item><!-- content -->
|
</b-tab-item><!-- content -->
|
||||||
</b-tabs>
|
</b-tabs>
|
||||||
</section>
|
</section>
|
||||||
|
@ -157,6 +170,8 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import { mapState } from 'vuex';
|
import { mapState } from 'vuex';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import htmlToPlainText from 'textversionjs';
|
||||||
|
|
||||||
import ListSelector from '../components/ListSelector.vue';
|
import ListSelector from '../components/ListSelector.vue';
|
||||||
import Editor from '../components/Editor.vue';
|
import Editor from '../components/Editor.vue';
|
||||||
|
|
||||||
|
@ -187,6 +202,7 @@ export default Vue.extend({
|
||||||
tags: [],
|
tags: [],
|
||||||
sendAt: null,
|
sendAt: null,
|
||||||
content: { contentType: 'richtext', body: '' },
|
content: { contentType: 'richtext', body: '' },
|
||||||
|
altbody: null,
|
||||||
|
|
||||||
// Parsed Date() version of send_at from the API.
|
// Parsed Date() version of send_at from the API.
|
||||||
sendAtDate: null,
|
sendAtDate: null,
|
||||||
|
@ -202,6 +218,22 @@ export default Vue.extend({
|
||||||
return dayjs(s).format('YYYY-MM-DD HH:mm');
|
return dayjs(s).format('YYYY-MM-DD HH:mm');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addAltBody() {
|
||||||
|
this.form.altbody = htmlToPlainText(this.form.content.body);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAltBody() {
|
||||||
|
this.form.altbody = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
if (this.isNew) {
|
||||||
|
this.createCampaign();
|
||||||
|
} else {
|
||||||
|
this.updateCampaign();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getCampaign(id) {
|
getCampaign(id) {
|
||||||
return this.$api.getCampaign(id).then((data) => {
|
return this.$api.getCampaign(id).then((data) => {
|
||||||
this.data = data;
|
this.data = data;
|
||||||
|
@ -233,6 +265,7 @@ export default Vue.extend({
|
||||||
template_id: this.form.templateId,
|
template_id: this.form.templateId,
|
||||||
content_type: this.form.content.contentType,
|
content_type: this.form.content.contentType,
|
||||||
body: this.form.content.body,
|
body: this.form.content.body,
|
||||||
|
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
|
||||||
subscribers: this.form.testEmails,
|
subscribers: this.form.testEmails,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -242,14 +275,6 @@ export default Vue.extend({
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
if (this.isNew) {
|
|
||||||
this.createCampaign();
|
|
||||||
} else {
|
|
||||||
this.updateCampaign();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
createCampaign() {
|
createCampaign() {
|
||||||
const data = {
|
const data = {
|
||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
|
@ -284,6 +309,7 @@ export default Vue.extend({
|
||||||
template_id: this.form.templateId,
|
template_id: this.form.templateId,
|
||||||
content_type: this.form.content.contentType,
|
content_type: this.form.content.contentType,
|
||||||
body: this.form.content.body,
|
body: this.form.content.body,
|
||||||
|
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let typMsg = 'globals.messages.updated';
|
let typMsg = 'globals.messages.updated';
|
||||||
|
|
|
@ -354,6 +354,7 @@ export default Vue.extend({
|
||||||
tags: c.tags,
|
tags: c.tags,
|
||||||
template_id: c.templateId,
|
template_id: c.templateId,
|
||||||
body: c.body,
|
body: c.body,
|
||||||
|
altbody: c.altbody,
|
||||||
};
|
};
|
||||||
this.$api.createCampaign(data).then((d) => {
|
this.$api.createCampaign(data).then((d) => {
|
||||||
this.$router.push({ name: 'campaign', params: { id: d.id } });
|
this.$router.push({ name: 'campaign', params: { id: d.id } });
|
||||||
|
|
|
@ -8531,6 +8531,11 @@ text-table@^0.2.0:
|
||||||
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||||
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
|
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
|
||||||
|
|
||||||
|
textversionjs@^1.1.3:
|
||||||
|
version "1.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/textversionjs/-/textversionjs-1.1.3.tgz#1b700aef780467786882e28ab126f77ca326a1e8"
|
||||||
|
integrity sha1-G3AK73gEZ3hoguKKsSb3fKMmoeg=
|
||||||
|
|
||||||
thenify-all@^1.0.0:
|
thenify-all@^1.0.0:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
|
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
|
||||||
|
|
|
@ -37,6 +37,8 @@
|
||||||
"campaigns.onlyPausedDraft": "Only paused campaigns and drafts can be started.",
|
"campaigns.onlyPausedDraft": "Only paused campaigns and drafts can be started.",
|
||||||
"campaigns.onlyScheduledAsDraft": "Only scheduled campaigns can be saved as drafts.",
|
"campaigns.onlyScheduledAsDraft": "Only scheduled campaigns can be saved as drafts.",
|
||||||
"campaigns.pause": "Pause",
|
"campaigns.pause": "Pause",
|
||||||
|
"campaigns.addAltText": "Add alternate plain text message",
|
||||||
|
"campaigns.removeAltText": "Remove alternate plain text message",
|
||||||
"campaigns.plainText": "Plain text",
|
"campaigns.plainText": "Plain text",
|
||||||
"campaigns.preview": "Preview",
|
"campaigns.preview": "Preview",
|
||||||
"campaigns.progress": "Progress",
|
"campaigns.progress": "Progress",
|
||||||
|
|
|
@ -79,6 +79,7 @@ type CampaignMessage struct {
|
||||||
to string
|
to string
|
||||||
subject string
|
subject string
|
||||||
body []byte
|
body []byte
|
||||||
|
altBody []byte
|
||||||
unsubURL string
|
unsubURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,6 +266,7 @@ func (m *Manager) messageWorker() {
|
||||||
Subject: msg.subject,
|
Subject: msg.subject,
|
||||||
ContentType: msg.Campaign.ContentType,
|
ContentType: msg.Campaign.ContentType,
|
||||||
Body: msg.body,
|
Body: msg.body,
|
||||||
|
AltBody: msg.altBody,
|
||||||
Subscriber: msg.Subscriber,
|
Subscriber: msg.Subscriber,
|
||||||
Campaign: msg.Campaign,
|
Campaign: msg.Campaign,
|
||||||
}
|
}
|
||||||
|
@ -299,6 +301,7 @@ func (m *Manager) messageWorker() {
|
||||||
Subject: msg.Subject,
|
Subject: msg.Subject,
|
||||||
ContentType: msg.ContentType,
|
ContentType: msg.ContentType,
|
||||||
Body: msg.Body,
|
Body: msg.Body,
|
||||||
|
AltBody: msg.AltBody,
|
||||||
Subscriber: msg.Subscriber,
|
Subscriber: msg.Subscriber,
|
||||||
Campaign: msg.Campaign,
|
Campaign: msg.Campaign,
|
||||||
})
|
})
|
||||||
|
@ -616,10 +619,25 @@ func (m *CampaignMessage) Render() error {
|
||||||
out.Reset()
|
out.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile the main template.
|
||||||
if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil {
|
if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.body = out.Bytes()
|
m.body = out.Bytes()
|
||||||
|
|
||||||
|
// Is there an alt body?
|
||||||
|
if m.Campaign.ContentType != models.CampaignContentTypePlain && m.Campaign.AltBody.Valid {
|
||||||
|
if m.Campaign.AltBodyTpl != nil {
|
||||||
|
b := bytes.Buffer{}
|
||||||
|
if err := m.Campaign.AltBodyTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.altBody = b.Bytes()
|
||||||
|
} else {
|
||||||
|
m.altBody = []byte(m.Campaign.AltBody.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -634,3 +652,10 @@ func (m *CampaignMessage) Body() []byte {
|
||||||
copy(out, m.body)
|
copy(out, m.body)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AltBody returns a copy of the message's alt body.
|
||||||
|
func (m *CampaignMessage) AltBody() []byte {
|
||||||
|
out := make([]byte, len(m.altBody))
|
||||||
|
copy(out, m.altBody)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
|
||||||
"github.com/jaytaylor/html2text"
|
|
||||||
"github.com/knadh/listmonk/internal/messenger"
|
"github.com/knadh/listmonk/internal/messenger"
|
||||||
"github.com/knadh/smtppool"
|
"github.com/knadh/smtppool"
|
||||||
)
|
)
|
||||||
|
@ -19,7 +18,6 @@ type Server struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
AuthProtocol string `json:"auth_protocol"`
|
AuthProtocol string `json:"auth_protocol"`
|
||||||
EmailFormat string `json:"email_format"`
|
|
||||||
TLSEnabled bool `json:"tls_enabled"`
|
TLSEnabled bool `json:"tls_enabled"`
|
||||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||||
EmailHeaders map[string]string `json:"email_headers"`
|
EmailHeaders map[string]string `json:"email_headers"`
|
||||||
|
@ -114,12 +112,6 @@ func (e *Emailer) Push(m messenger.Message) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mtext, err := html2text.FromString(string(m.Body),
|
|
||||||
html2text.Options{PrettyTables: true})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
em := smtppool.Email{
|
em := smtppool.Email{
|
||||||
From: m.From,
|
From: m.From,
|
||||||
To: m.To,
|
To: m.To,
|
||||||
|
@ -140,14 +132,14 @@ func (e *Emailer) Push(m messenger.Message) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch srv.EmailFormat {
|
switch m.ContentType {
|
||||||
case "html":
|
|
||||||
em.HTML = m.Body
|
|
||||||
case "plain":
|
case "plain":
|
||||||
em.Text = []byte(mtext)
|
em.Text = []byte(m.Body)
|
||||||
default:
|
default:
|
||||||
em.HTML = m.Body
|
em.HTML = m.Body
|
||||||
em.Text = []byte(mtext)
|
if len(m.AltBody) > 0 {
|
||||||
|
em.Text = m.AltBody
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return srv.pool.Send(em)
|
return srv.pool.Send(em)
|
||||||
|
|
|
@ -22,6 +22,7 @@ type Message struct {
|
||||||
Subject string
|
Subject string
|
||||||
ContentType string
|
ContentType string
|
||||||
Body []byte
|
Body []byte
|
||||||
|
AltBody []byte
|
||||||
Headers textproto.MIMEHeader
|
Headers textproto.MIMEHeader
|
||||||
Attachments []Attachment
|
Attachments []Attachment
|
||||||
|
|
||||||
|
|
|
@ -11,12 +11,15 @@ import (
|
||||||
// V0_9_0 performs the DB migrations for v.0.9.0.
|
// V0_9_0 performs the DB migrations for v.0.9.0.
|
||||||
func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(`
|
||||||
INSERT INTO settings (key, value) VALUES
|
INSERT INTO settings (key, value) VALUES
|
||||||
('app.lang', '"en"'),
|
('app.lang', '"en"'),
|
||||||
('app.message_sliding_window', 'false'),
|
('app.message_sliding_window', 'false'),
|
||||||
('app.message_sliding_window_duration', '"1h"'),
|
('app.message_sliding_window_duration', '"1h"'),
|
||||||
('app.message_sliding_window_rate', '10000')
|
('app.message_sliding_window_rate', '10000')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Add alternate (plain text) body field on campaigns.
|
||||||
|
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS altbody TEXT NULL DEFAULT NULL;
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,14 +29,17 @@ const (
|
||||||
SubscriptionStatusUnsubscribed = "unsubscribed"
|
SubscriptionStatusUnsubscribed = "unsubscribed"
|
||||||
|
|
||||||
// Campaign.
|
// Campaign.
|
||||||
CampaignStatusDraft = "draft"
|
CampaignStatusDraft = "draft"
|
||||||
CampaignStatusScheduled = "scheduled"
|
CampaignStatusScheduled = "scheduled"
|
||||||
CampaignStatusRunning = "running"
|
CampaignStatusRunning = "running"
|
||||||
CampaignStatusPaused = "paused"
|
CampaignStatusPaused = "paused"
|
||||||
CampaignStatusFinished = "finished"
|
CampaignStatusFinished = "finished"
|
||||||
CampaignStatusCancelled = "cancelled"
|
CampaignStatusCancelled = "cancelled"
|
||||||
CampaignTypeRegular = "regular"
|
CampaignTypeRegular = "regular"
|
||||||
CampaignTypeOptin = "optin"
|
CampaignTypeOptin = "optin"
|
||||||
|
CampaignContentTypeRichtext = "richtext"
|
||||||
|
CampaignContentTypeHTML = "html"
|
||||||
|
CampaignContentTypePlain = "plain"
|
||||||
|
|
||||||
// List.
|
// List.
|
||||||
ListTypePrivate = "private"
|
ListTypePrivate = "private"
|
||||||
|
@ -170,6 +173,7 @@ type Campaign struct {
|
||||||
Subject string `db:"subject" json:"subject"`
|
Subject string `db:"subject" json:"subject"`
|
||||||
FromEmail string `db:"from_email" json:"from_email"`
|
FromEmail string `db:"from_email" json:"from_email"`
|
||||||
Body string `db:"body" json:"body"`
|
Body string `db:"body" json:"body"`
|
||||||
|
AltBody null.String `db:"altbody" json:"altbody"`
|
||||||
SendAt null.Time `db:"send_at" json:"send_at"`
|
SendAt null.Time `db:"send_at" json:"send_at"`
|
||||||
Status string `db:"status" json:"status"`
|
Status string `db:"status" json:"status"`
|
||||||
ContentType string `db:"content_type" json:"content_type"`
|
ContentType string `db:"content_type" json:"content_type"`
|
||||||
|
@ -181,6 +185,7 @@ type Campaign struct {
|
||||||
TemplateBody string `db:"template_body" json:"-"`
|
TemplateBody string `db:"template_body" json:"-"`
|
||||||
Tpl *template.Template `json:"-"`
|
Tpl *template.Template `json:"-"`
|
||||||
SubjectTpl *template.Template `json:"-"`
|
SubjectTpl *template.Template `json:"-"`
|
||||||
|
AltBodyTpl *template.Template `json:"-"`
|
||||||
|
|
||||||
// Pseudofield for getting the total number of subscribers
|
// Pseudofield for getting the total number of subscribers
|
||||||
// in searches and queries.
|
// in searches and queries.
|
||||||
|
@ -321,6 +326,7 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error inserting child template: %v", err)
|
return fmt.Errorf("error inserting child template: %v", err)
|
||||||
}
|
}
|
||||||
|
c.Tpl = out
|
||||||
|
|
||||||
// If the subject line has a template string, compile it.
|
// If the subject line has a template string, compile it.
|
||||||
if strings.Contains(c.Subject, "{{") {
|
if strings.Contains(c.Subject, "{{") {
|
||||||
|
@ -335,7 +341,18 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
|
||||||
c.SubjectTpl = subjTpl
|
c.SubjectTpl = subjTpl
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Tpl = out
|
if strings.Contains(c.AltBody.String, "{{") {
|
||||||
|
b := c.AltBody.String
|
||||||
|
for _, r := range regTplFuncs {
|
||||||
|
b = r.regExp.ReplaceAllString(b, r.replace)
|
||||||
|
}
|
||||||
|
bTpl, err := template.New(ContentTpl).Funcs(f).Parse(b)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error compiling alt plaintext message: %v", err)
|
||||||
|
}
|
||||||
|
c.AltBodyTpl = bTpl
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
51
queries.sql
51
queries.sql
|
@ -354,16 +354,16 @@ WITH campLists AS (
|
||||||
-- Get the list_ids and their optin statuses for the campaigns found in the previous step.
|
-- Get the list_ids and their optin statuses for the campaigns found in the previous step.
|
||||||
SELECT id AS list_id, campaign_id, optin FROM lists
|
SELECT id AS list_id, campaign_id, optin FROM lists
|
||||||
INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
|
INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
|
||||||
WHERE id=ANY($12::INT[])
|
WHERE id=ANY($13::INT[])
|
||||||
),
|
),
|
||||||
tpl AS (
|
tpl AS (
|
||||||
-- If there's no template_id given, use the defualt template.
|
-- If there's no template_id given, use the defualt template.
|
||||||
SELECT (CASE WHEN $11 = 0 THEN id ELSE $11 END) AS id FROM templates WHERE is_default IS TRUE
|
SELECT (CASE WHEN $12 = 0 THEN id ELSE $12 END) AS id FROM templates WHERE is_default IS TRUE
|
||||||
),
|
),
|
||||||
counts AS (
|
counts AS (
|
||||||
SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
|
SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
|
||||||
FROM subscribers
|
FROM subscribers
|
||||||
LEFT JOIN campLists ON (campLists.campaign_id = ANY($12::INT[]))
|
LEFT JOIN campLists ON (campLists.campaign_id = ANY($13::INT[]))
|
||||||
LEFT JOIN subscriber_lists ON (
|
LEFT JOIN subscriber_lists ON (
|
||||||
subscriber_lists.status != 'unsubscribed' AND
|
subscriber_lists.status != 'unsubscribed' AND
|
||||||
subscribers.id = subscriber_lists.subscriber_id AND
|
subscribers.id = subscriber_lists.subscriber_id AND
|
||||||
|
@ -373,16 +373,16 @@ counts AS (
|
||||||
-- any status except for 'unsubscribed' (already excluded above) works.
|
-- any status except for 'unsubscribed' (already excluded above) works.
|
||||||
(CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END)
|
(CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END)
|
||||||
)
|
)
|
||||||
WHERE subscriber_lists.list_id=ANY($12::INT[])
|
WHERE subscriber_lists.list_id=ANY($13::INT[])
|
||||||
AND subscribers.status='enabled'
|
AND subscribers.status='enabled'
|
||||||
),
|
),
|
||||||
camp AS (
|
camp AS (
|
||||||
INSERT INTO campaigns (uuid, type, name, subject, from_email, body, content_type, send_at, tags, messenger, template_id, to_send, max_subscriber_id)
|
INSERT INTO campaigns (uuid, type, name, subject, from_email, body, altbody, 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 id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts)
|
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, (SELECT id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
INSERT INTO campaign_lists (campaign_id, list_id, list_name)
|
INSERT INTO campaign_lists (campaign_id, list_id, list_name)
|
||||||
(SELECT (SELECT id FROM camp), id, name FROM lists WHERE id=ANY($12::INT[]))
|
(SELECT (SELECT id FROM camp), id, name FROM lists WHERE id=ANY($13::INT[]))
|
||||||
RETURNING (SELECT id FROM camp);
|
RETURNING (SELECT id FROM camp);
|
||||||
|
|
||||||
-- name: query-campaigns
|
-- name: query-campaigns
|
||||||
|
@ -392,19 +392,19 @@ INSERT INTO campaign_lists (campaign_id, list_id, list_name)
|
||||||
-- there's a COUNT() OVER() that still returns the total result count
|
-- there's a COUNT() OVER() that still returns the total result count
|
||||||
-- for pagination in the frontend, albeit being a field that'll repeat
|
-- for pagination in the frontend, albeit being a field that'll repeat
|
||||||
-- with every resultant row.
|
-- with every resultant row.
|
||||||
SELECT campaigns.id, campaigns.uuid, campaigns.name, campaigns.subject, campaigns.from_email,
|
SELECT c.id, c.uuid, c.name, c.subject, c.from_email,
|
||||||
campaigns.messenger, campaigns.started_at, campaigns.to_send, campaigns.sent, campaigns.type,
|
c.messenger, c.started_at, c.to_send, c.sent, c.type,
|
||||||
campaigns.body, campaigns.send_at, campaigns.status, campaigns.content_type, campaigns.tags,
|
c.body, c.altbody, c.send_at, c.status, c.content_type, c.tags,
|
||||||
campaigns.template_id, campaigns.created_at, campaigns.updated_at,
|
c.template_id, c.created_at, c.updated_at,
|
||||||
COUNT(*) OVER () AS total,
|
COUNT(*) OVER () AS total,
|
||||||
(
|
(
|
||||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
|
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
|
||||||
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
|
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
|
||||||
campaign_lists.list_name AS name
|
campaign_lists.list_name AS name
|
||||||
FROM campaign_lists WHERE campaign_lists.campaign_id = campaigns.id
|
FROM campaign_lists WHERE campaign_lists.campaign_id = c.id
|
||||||
) l
|
) l
|
||||||
) AS lists
|
) AS lists
|
||||||
FROM campaigns
|
FROM campaigns c
|
||||||
WHERE ($1 = 0 OR id = $1)
|
WHERE ($1 = 0 OR id = $1)
|
||||||
AND status=ANY(CASE WHEN ARRAY_LENGTH($2::campaign_status[], 1) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END)
|
AND status=ANY(CASE WHEN ARRAY_LENGTH($2::campaign_status[], 1) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END)
|
||||||
AND ($3 = '' OR CONCAT(name, subject) ILIKE $3)
|
AND ($3 = '' OR CONCAT(name, subject) ILIKE $3)
|
||||||
|
@ -580,25 +580,26 @@ ORDER BY RANDOM() LIMIT 1;
|
||||||
-- name: update-campaign
|
-- name: update-campaign
|
||||||
WITH camp AS (
|
WITH camp AS (
|
||||||
UPDATE campaigns SET
|
UPDATE campaigns SET
|
||||||
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
|
name=$2,
|
||||||
subject=(CASE WHEN $3 != '' THEN $3 ELSE subject END),
|
subject=$3,
|
||||||
from_email=(CASE WHEN $4 != '' THEN $4 ELSE from_email END),
|
from_email=$4,
|
||||||
body=(CASE WHEN $5 != '' THEN $5 ELSE body END),
|
body=$5,
|
||||||
content_type=(CASE WHEN $6 != '' THEN $6::content_type ELSE content_type END),
|
altbody=(CASE WHEN $6 = '' THEN NULL ELSE $6 END),
|
||||||
send_at=(CASE WHEN $8 THEN $7::TIMESTAMP WITH TIME ZONE WHEN NOT $8 THEN NULL ELSE send_at END),
|
content_type=$7::content_type,
|
||||||
status=(CASE WHEN NOT $8 THEN 'draft' ELSE status END),
|
send_at=$8::TIMESTAMP WITH TIME ZONE,
|
||||||
tags=$9::VARCHAR(100)[],
|
status=(CASE WHEN NOT $9 THEN 'draft' ELSE status END),
|
||||||
messenger=(CASE WHEN $10 != '' THEN $10 ELSE messenger END),
|
tags=$10::VARCHAR(100)[],
|
||||||
template_id=(CASE WHEN $11 != 0 THEN $11 ELSE template_id END),
|
messenger=$11,
|
||||||
|
template_id=$12,
|
||||||
updated_at=NOW()
|
updated_at=NOW()
|
||||||
WHERE id = $1 RETURNING id
|
WHERE id = $1 RETURNING id
|
||||||
),
|
),
|
||||||
d AS (
|
d AS (
|
||||||
-- Reset list relationships
|
-- Reset list relationships
|
||||||
DELETE FROM campaign_lists WHERE campaign_id = $1 AND NOT(list_id = ANY($12))
|
DELETE FROM campaign_lists WHERE campaign_id = $1 AND NOT(list_id = ANY($13))
|
||||||
)
|
)
|
||||||
INSERT INTO campaign_lists (campaign_id, list_id, list_name)
|
INSERT INTO campaign_lists (campaign_id, list_id, list_name)
|
||||||
(SELECT $1 as campaign_id, id, name FROM lists WHERE id=ANY($12::INT[]))
|
(SELECT $1 as campaign_id, id, name FROM lists WHERE id=ANY($13::INT[]))
|
||||||
ON CONFLICT (campaign_id, list_id) DO UPDATE SET list_name = EXCLUDED.list_name;
|
ON CONFLICT (campaign_id, list_id) DO UPDATE SET list_name = EXCLUDED.list_name;
|
||||||
|
|
||||||
-- name: update-campaign-counts
|
-- name: update-campaign-counts
|
||||||
|
|
|
@ -75,6 +75,7 @@ CREATE TABLE campaigns (
|
||||||
subject TEXT NOT NULL,
|
subject TEXT NOT NULL,
|
||||||
from_email TEXT NOT NULL,
|
from_email TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
|
altbody TEXT NULL,
|
||||||
content_type content_type NOT NULL DEFAULT 'richtext',
|
content_type content_type NOT NULL DEFAULT 'richtext',
|
||||||
send_at TIMESTAMP WITH TIME ZONE,
|
send_at TIMESTAMP WITH TIME ZONE,
|
||||||
status campaign_status NOT NULL DEFAULT 'draft',
|
status campaign_status NOT NULL DEFAULT 'draft',
|
||||||
|
|
Loading…
Reference in New Issue