WIP: Add support for publishing campaigns to publish archives.

This commit is contained in:
Kailash Nadh 2022-11-03 11:07:26 +05:30
parent 74322cda36
commit 9add728b08
41 changed files with 697 additions and 49 deletions

172
cmd/archive.go Normal file
View file

@ -0,0 +1,172 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
null "gopkg.in/volatiletech/null.v6"
)
type campArchive struct {
UUID string `json:"uuid"`
Subject string `json:"subject"`
CreatedAt null.Time `json:"created_at"`
URL string `json:"url"`
}
// handleGetCampaignArchives renders the public campaign archives page.
func handleGetCampaignArchives(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams(), 50)
)
camps, total, err := getCampaignArchives(pg.Offset, pg.Limit, app)
if err != nil {
return err
}
var out models.PageResults
if len(camps) == 0 {
out.Results = []campArchive{}
return c.JSON(http.StatusOK, okResp{out})
}
// Meta.
out.Results = camps
out.Total = total
out.Page = pg.Page
out.PerPage = pg.PerPage
return c.JSON(200, okResp{out})
}
// handleCampaignArchivesPage renders the public campaign archives page.
func handleCampaignArchivesPage(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams(), 50)
)
out, total, err := getCampaignArchives(pg.Offset, pg.Limit, app)
if err != nil {
return err
}
title := app.i18n.T("public.archiveTitle")
return c.Render(http.StatusOK, "archive", struct {
Title string
Description string
Campaigns []campArchive
Total int
Page int
PerPage int
}{title, title, out, total, pg.Page, pg.PerPage})
}
// handleCampaignArchivePage renders the public campaign archives page.
func handleCampaignArchivePage(c echo.Context) error {
var (
app = c.Get("app").(*App)
uuid = c.Param("uuid")
)
pubCamp, err := app.core.GetArchivedCampaign(0, uuid)
if err != nil {
if er, ok := err.(*echo.HTTPError); ok {
if er.Code == http.StatusBadRequest {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound")))
}
}
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}
out, err := compileArchiveCampaigns([]models.Campaign{pubCamp}, app)
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}
// Render the message body.
camp := out[0].Campaign
msg, err := app.manager.NewCampaignMessage(camp, out[0].Subscriber)
if err != nil {
app.log.Printf("error rendering message: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}
return c.HTML(http.StatusOK, string(msg.Body()))
}
func getCampaignArchives(offset, limit int, app *App) ([]campArchive, int, error) {
pubCamps, total, err := app.core.GetArchivedCampaigns(offset, limit)
if err != nil {
return []campArchive{}, total, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
}
msgs, err := compileArchiveCampaigns(pubCamps, app)
if err != nil {
return []campArchive{}, total, err
}
out := make([]campArchive, 0, len(msgs))
for _, m := range msgs {
camp := m.Campaign
out = append(out, campArchive{
UUID: camp.UUID,
Subject: camp.Subject,
CreatedAt: camp.CreatedAt,
URL: app.constants.ArchiveURL + "/" + camp.UUID,
})
}
return out, total, nil
}
func compileArchiveCampaigns(camps []models.Campaign, app *App) ([]manager.CampaignMessage, error) {
var (
b = bytes.Buffer{}
)
out := make([]manager.CampaignMessage, 0, len(camps))
for _, camp := range camps {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
}
// Load the dummy subscriber meta.
var sub models.Subscriber
if err := json.Unmarshal([]byte(camp.ArchiveMeta), &sub); err != nil {
app.log.Printf("error unmarshalling campaign archive meta: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
}
m := manager.CampaignMessage{
Campaign: &camp,
Subscriber: sub,
}
// Render the subject if it's a template.
if camp.SubjectTpl != nil {
if err := camp.SubjectTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil {
return nil, err
}
camp.Subject = b.String()
b.Reset()
}
out = append(out, m)
}
return out, nil
}

View file

@ -2,6 +2,7 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
@ -215,6 +216,10 @@ func handleCreateCampaign(c echo.Context) error {
o = c
}
if o.ArchiveTemplateID == 0 {
o.ArchiveTemplateID = o.TemplateID
}
out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs)
if err != nil {
return err
@ -294,6 +299,31 @@ func handleUpdateCampaignStatus(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}
// handleUpdateCampaignArchive handles campaign status modification.
func handleUpdateCampaignArchive(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
req := struct {
Archive bool `json:"archive"`
TemplateID int `json:"archive_template_id"`
Meta models.JSON `json:"archive_meta"`
}{}
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}
if err := app.core.UpdateCampaignArchive(id, req.Archive, req.TemplateID, req.Meta); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleDeleteCampaign handles campaign deletion.
// Only scheduled campaigns that have not started yet can be deleted.
func handleDeleteCampaign(c echo.Context) error {
@ -529,6 +559,10 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
c.Headers = make([]map[string]string, 0)
}
if len(c.ArchiveMeta) == 0 {
c.ArchiveMeta = json.RawMessage("{}")
}
return c, nil
}

View file

@ -131,6 +131,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
g.POST("/api/campaigns", handleCreateCampaign)
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
g.PUT("/api/campaigns/:id/archive", handleUpdateCampaignArchive)
g.DELETE("/api/campaigns/:id", handleDeleteCampaign)
g.GET("/api/media", handleGetMedia)
@ -164,6 +165,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
// Public API endpoints.
e.GET("/api/public/lists", handleGetPublicLists)
e.POST("/api/public/subscription", handlePublicSubscription)
e.GET("/api/public/archive", handleGetCampaignArchives)
// /public/static/* file server is registered in initHTTPServer().
// Public subscriber facing views.
@ -185,6 +187,8 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
"campUUID", "subUUID")))
e.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView,
"campUUID", "subUUID")))
e.GET("/archive", handleCampaignArchivesPage)
e.GET("/archive/:uuid", handleCampaignArchivePage)
e.GET("/public/custom.css", serveCustomApperance("public.custom_css"))
e.GET("/public/custom.js", serveCustomApperance("public.custom_js"))

View file

@ -82,6 +82,7 @@ type constants struct {
ViewTrackURL string
OptinURL string
MessageURL string
ArchiveURL string
MediaProvider string
BounceWebhooksEnabled bool
@ -370,6 +371,9 @@ func initConstants() *constants {
// url.com/link/{campaign_uuid}/{subscriber_uuid}
c.MessageURL = fmt.Sprintf("%s/campaign/%%s/%%s", c.RootURL)
// url.com/archive
c.ArchiveURL = c.RootURL + "/archive"
// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL)
@ -424,6 +428,7 @@ func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Ma
LinkTrackURL: cs.LinkTrackURL,
ViewTrackURL: cs.ViewTrackURL,
MessageURL: cs.MessageURL,
ArchiveURL: cs.ArchiveURL,
UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
SlidingWindow: ko.Bool("app.message_sliding_window"),
SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"),

View file

@ -147,6 +147,9 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
emailMsgr,
campTplID,
pq.Int64Array{1},
false,
campTplID,
"{}",
); err != nil {
lo.Fatalf("error creating sample campaign: %v", err)
}

View file

@ -38,7 +38,7 @@ func (r *runnerDB) NextSubscribers(campID, limit int) ([]models.Subscriber, erro
// GetCampaign fetches a campaign from the database.
func (r *runnerDB) GetCampaign(campID int) (*models.Campaign, error) {
var out = &models.Campaign{}
err := r.queries.GetCampaign.Get(out, campID, nil)
err := r.queries.GetCampaign.Get(out, campID, nil, "default")
return out, err
}

View file

@ -139,7 +139,6 @@ func handleViewCampaignMessage(c echo.Context) error {
}
}
app.log.Printf("error fetching campaign: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}

View file

@ -0,0 +1,52 @@
const apiUrl = Cypress.env('apiUrl');
describe('Archive', () => {
it('Opens campaigns page', () => {
cy.resetDB();
cy.loginAndVisit('/campaigns');
cy.wait(500);
});
it('Clones campaign', () => {
cy.loginAndVisit('/campaigns');
cy.get('[data-cy=btn-clone]').first().click();
cy.get('.modal input').clear().type('clone').click();
cy.get('.modal button.is-primary').click();
cy.wait(250);
cy.clickMenu('all-campaigns');
});
it('Starts un-archived campaign', () => {
cy.get('td[data-label=Status] a').eq(0).click();
cy.get('[data-cy=btn-start]').click();
cy.get('.modal button.is-primary').click();
cy.wait(1000);
});
it('Enables archive on one campaign', () => {
cy.loginAndVisit('/campaigns');
cy.wait(250);
cy.get('td[data-label=Status] a').eq(1).click();
// Switch to archive tab and enable archive.
cy.get('.b-tabs nav a').eq(2).click();
cy.wait(500);
cy.get('[data-cy=btn-archive] .check').click();
cy.get('[data-cy=archive-meta]').clear()
.type('{"email": "archive@domain.com", "name": "Archive", "attribs": { "city": "Bengaluru"}}', { 'parseSpecialCharSequences': false });
// Start the campaign.
cy.get('[data-cy=btn-save]').click();
cy.wait(500);
cy.get('[data-cy=btn-start]').click();
cy.get('.modal button.is-primary').click();
cy.wait(1000);
});
it('Opens campaign archive page', () => {
cy.loginAndVisit(`${apiUrl}/archive`);
cy.get('li a').click();
cy.get('h3').contains('Hi Archive!');
cy.get('p').eq(0).contains('Bengaluru');
});
});

View file

@ -238,6 +238,9 @@ export const updateCampaign = async (id, data) => http.put(`/api/campaigns/${id}
export const changeCampaignStatus = async (id, status) => http.put(`/api/campaigns/${id}/status`,
{ status }, { loading: models.campaigns });
export const updateCampaignArchive = async (id, data) => http.put(`/api/campaigns/${id}/archive`, data,
{ loading: models.campaigns });
export const deleteCampaign = async (id) => http.delete(`/api/campaigns/${id}`,
{ loading: models.campaigns });

View file

@ -49,7 +49,7 @@
<b-tabs type="is-boxed" :animated="false" v-model="activeTab" @input="onTab">
<b-tab-item :label="$tc('globals.terms.campaign')" label-position="on-border"
icon="rocket-launch-outline">
value="campaign" icon="rocket-launch-outline">
<section class="wrap">
<div class="columns">
<div class="column is-7">
@ -172,7 +172,7 @@
</section>
</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" value="content">
<editor
v-model="form.content"
:id="data.id"
@ -198,6 +198,39 @@
type="textarea" :disabled="!canEdit" />
</div>
</b-tab-item><!-- content -->
<b-tab-item :label="$t('campaigns.archive')" icon="newspaper-variant-outline"
value="archive" :disabled="isNew">
<section class="wrap">
<b-field :label="$t('campaigns.archiveEnable')" data-cy="btn-archive"
:message="$t('campaigns.archiveHelp')">
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
</b-field>
<b-field :label="$tc('globals.terms.template')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.template')" v-model="form.archiveTemplateId"
name="template" :disabled="!canArchive || !form.archive" required>
<template v-for="t in templates">
<option v-if="t.type === 'campaign'"
:value="t.id" :key="t.id">{{ t.name }}</option>
</template>
</b-select>
</b-field>
<b-field :label="$t('campaigns.archiveMeta')"
:message="$t('campaigns.archiveMetaHelp')" label-position="on-border">
<b-input v-model="form.archiveMetaStr" name="archive_meta" type="textarea"
data-cy="archive-meta" :disabled="!canArchive || !form.archive" rows="20" />
</b-field>
<b-field v-if="!canEdit && canArchive">
<b-button @click="onUpdateCampaignArchive" :loading="loading.campaigns"
type="is-primary" icon-left="content-save-outline" data-cy="btn-archive-save">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
</b-field>
</section>
</b-tab-item><!-- archive -->
</b-tabs>
</section>
</template>
@ -211,6 +244,8 @@ import htmlToPlainText from 'textversionjs';
import ListSelector from '../components/ListSelector.vue';
import Editor from '../components/Editor.vue';
const TABS = ['campaign', 'content', 'archive'];
export default Vue.extend({
components: {
ListSelector,
@ -247,7 +282,9 @@ export default Vue.extend({
// Parsed Date() version of send_at from the API.
sendAtDate: null,
sendLater: false,
archive: false,
archiveMetaStr: '{}',
archiveMeta: {},
testEmails: [],
},
};
@ -276,7 +313,8 @@ export default Vue.extend({
},
onTab(t) {
if (t === 1 && window.tinymce && window.tinymce.editors.length > 0) {
const tab = TABS[t];
if (tab === 'content' && window.tinymce && window.tinymce.editors.length > 0) {
this.$nextTick(() => {
window.tinymce.editors[0].focus();
});
@ -284,6 +322,7 @@ export default Vue.extend({
},
onSubmit(typ) {
// Validate custom JSON headers.
if (this.form.headersStr && this.form.headersStr !== '[]') {
try {
this.form.headers = JSON.parse(this.form.headersStr);
@ -295,6 +334,23 @@ export default Vue.extend({
this.form.headers = [];
}
// Validate archive JSON body.
if (this.form.archive && this.form.archiveMetaStr) {
try {
this.form.archiveMeta = JSON.parse(this.form.archiveMetaStr);
} catch (e) {
this.$utils.toast(e.toString(), 'is-danger');
return;
}
} else {
this.form.archiveMeta = {};
}
// Cache the campaign archive metadata for the next one.
if (this.isEditing) {
this.$utils.setPref('campaign.archiveMetaStr', this.form.archiveMetaStr);
}
switch (typ) {
case 'create':
this.createCampaign();
@ -315,11 +371,17 @@ export default Vue.extend({
...this.form,
...data,
headersStr: JSON.stringify(data.headers, null, 4),
archiveMetaStr: data.archiveMeta ? JSON.stringify(data.archiveMeta, null, 4) : '{}',
// The structure that is populated by editor input event.
content: { contentType: data.contentType, body: data.body },
};
if (this.form.archiveMetaStr === '{}') {
const archiveStr = `{"email": "email@domain.com", "name": "${this.$t('globals.fields.name')}", "attribs": {}}`;
this.form.archiveMetaStr = this.$utils.getPref('campaign.archiveMetaStr') || JSON.stringify(JSON.parse(archiveStr), null, 4);
}
if (data.sendAt !== null) {
this.form.sendLater = true;
this.form.sendAtDate = dayjs(data.sendAt).toDate();
@ -390,6 +452,9 @@ export default Vue.extend({
content_type: this.form.content.contentType,
body: this.form.content.body,
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
archive: this.form.archive,
archive_template_id: this.form.archiveTemplateId,
archive_meta: this.form.archiveMeta,
};
let typMsg = 'globals.messages.updated';
@ -407,6 +472,20 @@ export default Vue.extend({
});
},
onUpdateCampaignArchive() {
if (this.isEditing && this.canEdit) {
return;
}
const data = {
archive: this.form.archive,
archive_template_id: this.form.archiveTemplateId,
archive_meta: JSON.parse(this.form.archiveMetaStr),
};
this.$api.updateCampaignArchive(this.data.id, data);
},
// Starts or schedule a campaign.
startCampaign() {
if (!this.canStart && !this.canSchedule) {
@ -451,6 +530,10 @@ export default Vue.extend({
return this.data.status === 'draft' && !this.data.sendAt;
},
canArchive() {
return this.data.status !== 'cancelled';
},
selectedLists() {
if (this.selListIDs.length === 0 || !this.lists.results) {
return [];
@ -481,11 +564,11 @@ export default Vue.extend({
mounted() {
window.onbeforeunload = () => this.isUnsaved() || null;
// Fill default form fields.
this.form.fromEmail = this.settings['app.from_email'];
const { id } = this.$route.params;
// New campaign.
const { id } = this.$route.params;
if (id === 'new') {
this.isNew = true;

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Servei desconegut",
"bounces.view": "Veure rebots",
"campaigns.addAltText": "Afegeix un missatge de text pla alternatiu",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "No es pot actualitzar una campanya en curs o ja finalitzada.",
"campaigns.clicks": "Clics",
"campaigns.confirmDelete": "Esborra {name}",
@ -281,6 +286,8 @@
"menu.media": "Mèdia",
"menu.newCampaign": "Crea nova",
"menu.settings": "Configuració",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "No s'ha trobat el missatge de correu electrònic.",
"public.confirmOptinSubTitle": "Confirmació de la subscripció",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Neznámá služba.",
"bounces.view": "Zobrazit převzetí",
"campaigns.addAltText": "Přidat alternativní zprávu ve formátu prostého textu",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Nelze aktualizovat spuštěnou nebo dokončenou kampaň.",
"campaigns.clicks": "Klepnutí",
"campaigns.confirmDelete": "Odstranit {name}",
@ -282,6 +287,8 @@
"menu.media": "Médium",
"menu.newCampaign": "Vytvořit nový",
"menu.settings": "Nastavení",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "E-mailová zpráva nebyla nalezena.",
"public.confirmOptinSubTitle": "Potvrdit odběr",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Unbekannter Dienst.",
"bounces.view": "Bounces anzeigen",
"campaigns.addAltText": "Füge eine alternative Nachricht in unformatierten Text hinzu (falls HTML nicht angezeigt werden kann).",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Eine laufende oder abgeschlossene Kampagne kann nicht geändert werden.",
"campaigns.clicks": "Klicks",
"campaigns.confirmDelete": "Lösche {name}",
@ -282,6 +287,8 @@
"menu.media": "Medien",
"menu.newCampaign": "Neu Anlegen",
"menu.settings": "Einstellungen",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Die E-Mail wurde nicht gefunden.",
"public.confirmOptinSubTitle": "Abonnement bestätigen",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Unknown service.",
"bounces.view": "View bounces",
"campaigns.addAltText": "Add alternate plain text message",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Cannot update a running or a finished campaign.",
"campaigns.clicks": "Clicks",
"campaigns.confirmDelete": "Delete {name}",
@ -281,6 +286,8 @@
"menu.media": "Media",
"menu.newCampaign": "Create new",
"menu.settings": "Settings",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "The e-mail message was not found.",
"public.confirmOptinSubTitle": "Confirm subscription",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Servicio desconocido.",
"bounces.view": "Ver rebotes",
"campaigns.addAltText": "Agregar mensaje en texto plano alternativo",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "No es posible actualizar una campaña iniciada o finalizada.",
"campaigns.clicks": "Clics",
"campaigns.confirmDelete": "Eliminar {name}",
@ -282,6 +287,8 @@
"menu.media": "Multimedia",
"menu.newCampaign": "Crear nueva",
"menu.settings": "Configuraciones",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "El mensaje de correo electrónico no fue encontrado",
"public.confirmOptinSubTitle": "Confirmar subscripción",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Tuntematon palvelu.",
"bounces.view": "Näytä epäonnistuneet toimitukset",
"campaigns.addAltText": "Lisää vaihtoehtoinen tekstimuotoinen viesti",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Käynnissä olevaa tai päättynyttä kampanjaa ei voi päivittää.",
"campaigns.clicks": "Klikkaukset",
"campaigns.confirmDelete": "Poista {name}",
@ -282,6 +287,8 @@
"menu.media": "Media",
"menu.newCampaign": "Create new",
"menu.settings": "Settings",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Sähköpostiviestiä ei löytynyt",
"public.confirmOptinSubTitle": "Vahvista uutiskirjeen tilaus",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Service inconnu.",
"bounces.view": "Voir les rebonds",
"campaigns.addAltText": "Ajouter un message alternatif en texte brut",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Impossible de mettre à jour une campagne en cours ou terminée.",
"campaigns.clicks": "Clics",
"campaigns.confirmDelete": "Supprimer la campagne {name}",
@ -282,6 +287,8 @@
"menu.media": "Fichiers",
"menu.newCampaign": "Nouvelle campagne",
"menu.settings": "Paramètres",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "La liste de diffusion est introuvable.",
"public.confirmOptinSubTitle": "Confirmer votre abonnement",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Ismeretlen szolgáltatás.",
"bounces.view": "Visszapattanások megtekintése",
"campaigns.addAltText": "Alternatív egyszerű szöveges üzenet hozzáadása",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Nem lehet frissíteni a futó vagy a befejezett kampányt.",
"campaigns.clicks": "Kattintások",
"campaigns.confirmDelete": "Törlés {name}",
@ -282,6 +287,8 @@
"menu.media": "Média",
"menu.newCampaign": "Új készítése",
"menu.settings": "Beállítások",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Az e-mail üzenet nem található.",
"public.confirmOptinSubTitle": "Feliratkozás megerősítése",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Servizio sconosciuto.",
"bounces.view": "Visualizza i rimbalzi",
"campaigns.addAltText": "Aggiungere un messaggio sostitutivo in testo semplice",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Impossibile aggiornare una campagna in corso o già effettuata.",
"campaigns.clicks": "Clic",
"campaigns.confirmDelete": "Cancellare {nome}",
@ -282,6 +287,8 @@
"menu.media": "Media",
"menu.newCampaign": "Creare nuovo",
"menu.settings": "Impostazioni",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Newsletter impossibile da trovare.",
"public.confirmOptinSubTitle": "Confermare l'iscrizione",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "不明のサービス。",
"bounces.view": "バウンスビュー",
"campaigns.addAltText": "代替のプレーンテキストメッセージを追加する",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "実行中又は終了しているキャンペーンの更新はできません。",
"campaigns.clicks": "クリック",
"campaigns.confirmDelete": "削除 {name}",
@ -282,6 +287,8 @@
"menu.media": "メディア",
"menu.newCampaign": "新規作成",
"menu.settings": "設定",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "メールのメッセージが見つかりませんでした。",
"public.confirmOptinSubTitle": "サブスクリプション確認",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Unknown service.",
"bounces.view": "View bounces",
"campaigns.addAltText": "Add alternate plain text message",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "ഇപ്പോൾ നടന്നുകൊണ്ടിരിയ്ക്കുന്നതോ, അവസാനിച്ചതോ ആയ ക്യാമ്പേയ്ൻ പുതുക്കാനാകില്ല.",
"campaigns.clicks": "ക്ലീക്കുകൾ",
"campaigns.confirmDelete": "{name} നീക്കം ചെയ്യുക",
@ -282,6 +287,8 @@
"menu.media": "മീഡിയ",
"menu.newCampaign": "പുതിയത് തുടങ്ങുക",
"menu.settings": "ക്രമീകരണങ്ങൾ",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "ഇ-മെയിൽ കണ്ടെത്താനായില്ല.",
"public.confirmOptinSubTitle": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Onbekende service.",
"bounces.view": "Zie bounces",
"campaigns.addAltText": "Voeg alternatieve tekst zonder opmaak toe",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Kan een lopende of afgelopen campagne niet updaten.",
"campaigns.clicks": "Kliks",
"campaigns.confirmDelete": "Verwijder {name}",
@ -282,6 +287,8 @@
"menu.media": "Media",
"menu.newCampaign": "Nieuwe aanmaken",
"menu.settings": "Instellingen",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Het e-mailbericht werd niet gevonden.",
"public.confirmOptinSubTitle": "Bevestig inschrijving",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Nieznane usługi.",
"bounces.view": "Zobacz odbicia",
"campaigns.addAltText": "Dodaj alternatywną wiadomość jako plain text",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Nie można aktualizować aktywnej ani zakończonej kampanii",
"campaigns.clicks": "Kliknięcia",
"campaigns.confirmDelete": "Usuń {name}",
@ -282,6 +287,8 @@
"menu.media": "Media",
"menu.newCampaign": "Utwórz nową",
"menu.settings": "Ustawienia",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Wiadomość email nie została znaleziona.",
"public.confirmOptinSubTitle": "Potwierdź subskrypcję",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Serviço desconhecido.",
"bounces.view": "Ver bounces",
"campaigns.addAltText": "Adicionar mensagem alternativa em texto simples",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Não é possível atualizar uma campanha em execução ou finalizada.",
"campaigns.clicks": "Cliques",
"campaigns.confirmDelete": "Excluir {name}",
@ -282,6 +287,8 @@
"menu.media": "Mídia",
"menu.newCampaign": "Criar nova",
"menu.settings": "Configurações",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "A mensagem do e-mail não foi encontrada.",
"public.confirmOptinSubTitle": "Confirmar a assinatura",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Unknown service.",
"bounces.view": "View bounces",
"campaigns.addAltText": "Adicionar mensagem alternativa em texto simples",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Não é possível atualizar uma campanha em curso ou terminada.",
"campaigns.clicks": "Cliques",
"campaigns.confirmDelete": "Eliminar {name}",
@ -282,6 +287,8 @@
"menu.media": "Mídia",
"menu.newCampaign": "Criar nova",
"menu.settings": "Definições",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "A mensagem de email não foi encontrada.",
"public.confirmOptinSubTitle": "Confirmar subscrição",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Serviciu necunoscut.",
"bounces.view": "Vizualizeaz[ respingeri",
"campaigns.addAltText": "Adaug[ un text simplu alternativ",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Nu se poate actualiza o campaniedifuzată sau terminată",
"campaigns.clicks": "Clickuri",
"campaigns.confirmDelete": "Sterge {nume}",
@ -282,6 +287,8 @@
"menu.media": "Media",
"menu.newCampaign": "Creaza nou",
"menu.settings": "Setări",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Mesajul emailului nu a fost găsit.",
"public.confirmOptinSubTitle": "Confirmă abonarea",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Неизвестная услуга.",
"bounces.view": "Просмотр отскоков",
"campaigns.addAltText": "Добавить альтернативное простое текстовое сообщение",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Не возможно обновить запущенную или завершённую компанию.",
"campaigns.clicks": "Клики",
"campaigns.confirmDelete": "Удалить {name}",
@ -282,6 +287,8 @@
"menu.media": "Медиа",
"menu.newCampaign": "Создать новую",
"menu.settings": "Параметры",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Письмо не было найдено.",
"public.confirmOptinSubTitle": "Подтверждение подписки",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Unknown service.",
"bounces.view": "View bounces",
"campaigns.addAltText": "Alternatif düz metin ekleyin",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Gönderilmekte olan veya gönderilmiş kampaynalar güncellenemez.",
"campaigns.clicks": "Tıklama",
"campaigns.confirmDelete": "Sil {name}",
@ -282,6 +287,8 @@
"menu.media": "Medya",
"menu.newCampaign": "Yeni oluştur",
"menu.settings": "Ayarlar",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "E-posta mesajı bulunamadı.",
"public.confirmOptinSubTitle": "Üyeliği doğrula",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "Dịch vụ không xác định.",
"bounces.view": "Xem thư bị trả lại",
"campaigns.addAltText": "Thêm tin nhắn văn bản thuần túy thay thế",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "Không thể cập nhật chiến dịch đang chạy hoặc đã kết thúc.",
"campaigns.clicks": "Số lần nhấp chuột",
"campaigns.confirmDelete": "Xóa {name}",
@ -282,6 +287,8 @@
"menu.media": "Dữ liệu truyền thông",
"menu.newCampaign": "Tạo mới",
"menu.settings": "Cài đặt",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Tin nhắn e-mail không được tìm thấy.",
"public.confirmOptinSubTitle": "Xác nhận đăng ký",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "未知的服务。",
"bounces.view": "查看退回邮",
"campaigns.addAltText": "添加备用纯文本消息",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "无法更新正在运行或已完成的广告系列。",
"campaigns.clicks": "点击次数",
"campaigns.confirmDelete": "删除{名称}",
@ -282,6 +287,8 @@
"menu.media": "媒体",
"menu.newCampaign": "创建新的",
"menu.settings": "设置",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "未找到电子邮件。",
"public.confirmOptinSubTitle": "确认订阅",

View file

@ -14,6 +14,11 @@
"bounces.unknownService": "未知的服務。",
"bounces.view": "查看退回郵",
"campaigns.addAltText": "添加備用純文本消息",
"campaigns.archive": "Archive",
"campaigns.archiveEnable": "Publish to public archive",
"campaigns.archiveHelp": "Publish (running, paused, finished) the campaign message on the public archive.",
"campaigns.archiveMeta": "Campaign metadata",
"campaigns.archiveMetaHelp": "Dummy subscriber data to use in the public message including name, email, and any optional attributes used in the campaign message or template.",
"campaigns.cantUpdate": "無法更新正在運行或已完成的廣告系列。",
"campaigns.clicks": "點擊次數",
"campaigns.confirmDelete": "刪除{名稱}",
@ -282,6 +287,8 @@
"menu.media": "媒體",
"menu.newCampaign": "創建新的",
"menu.settings": "設置",
"public.archiveEmpty": "No archived messages yet.",
"public.archiveTitle": "Mailing list archive",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "未找到電子郵件。",
"public.confirmOptinSubTitle": "確認訂閱",

View file

@ -16,6 +16,9 @@ const (
CampaignAnalyticsViews = "views"
CampaignAnalyticsClicks = "clicks"
CampaignAnalyticsBounces = "bounces"
campaignTplDefault = "default"
campaignTplArchive = "archive"
)
// QueryCampaigns retrieves paginated campaigns optionally filtering them by the given arbitrary
@ -59,6 +62,28 @@ func (c *Core) QueryCampaigns(searchStr string, statuses []string, orderBy, orde
// GetCampaign retrieves a campaign.
func (c *Core) GetCampaign(id int, uuid string) (models.Campaign, error) {
return c.getCampaign(id, uuid, campaignTplDefault)
}
// GetArchivedCampaign retreives a campaign with the archive template body.
func (c *Core) GetArchivedCampaign(id int, uuid string) (models.Campaign, error) {
out, err := c.getCampaign(id, uuid, campaignTplArchive)
if err != nil {
return out, err
}
if !out.Archive {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
return out, nil
}
// getCampaign retrieves a campaign. If typlType=default, then the campaign's
// template body is returned as "template_body". If tplType="archive",
// the archive template is returned.
func (c *Core) getCampaign(id int, uuid string, tplType string) (models.Campaign, error) {
// Unsafe to ignore scanning fields not present in models.Campaigns.
var uu interface{}
if uuid != "" {
@ -66,7 +91,7 @@ func (c *Core) GetCampaign(id int, uuid string) (models.Campaign, error) {
}
var out models.Campaigns
if err := c.q.GetCampaign.Select(&out, id, uu); err != nil {
if err := c.q.GetCampaign.Select(&out, id, uu, tplType); err != nil {
// if err := c.db.Select(&out, stmt, 0, pq.Array([]string{}), queryStr, 0, 1); err != nil {
c.log.Printf("error fetching campaign: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
@ -76,7 +101,6 @@ func (c *Core) GetCampaign(id int, uuid string) (models.Campaign, error) {
if len(out) == 0 {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
for i := 0; i < len(out); i++ {
@ -113,6 +137,23 @@ func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) {
return out, nil
}
// GetArchivedCampaigns retrieves campaigns with a template body.
func (c *Core) GetArchivedCampaigns(offset, limit int) (models.Campaigns, int, error) {
var out models.Campaigns
if err := c.q.GetArchivedCampaigns.Select(&out, offset, limit); err != nil {
c.log.Printf("error fetching public campaigns: %v", err)
return models.Campaigns{}, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
total := 0
if len(out) > 0 {
total = out[0].Total
}
return out, total, nil
}
// CreateCampaign creates a new campaign.
func (c *Core) CreateCampaign(o models.Campaign, listIDs []int) (models.Campaign, error) {
uu, err := uuid.NewV4()
@ -139,6 +180,9 @@ func (c *Core) CreateCampaign(o models.Campaign, listIDs []int) (models.Campaign
o.Messenger,
o.TemplateID,
pq.Array(listIDs),
o.Archive,
o.ArchiveTemplateID,
o.ArchiveMeta,
); err != nil {
if err == sql.ErrNoRows {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs"))
@ -172,7 +216,10 @@ func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, sendLate
pq.StringArray(normalizeTags(o.Tags)),
o.Messenger,
o.TemplateID,
pq.Array(listIDs))
pq.Array(listIDs),
o.Archive,
o.ArchiveTemplateID,
o.ArchiveMeta)
if err != nil {
c.log.Printf("error updating campaign: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
@ -243,6 +290,18 @@ func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, err
return cm, nil
}
// UpdateCampaignArchive updates a campaign's archive properties.
func (c *Core) UpdateCampaignArchive(id int, enabled bool, tplID int, meta models.JSON) error {
if _, err := c.q.UpdateCampaignArchive.Exec(id, enabled, tplID, meta); err != nil {
c.log.Printf("error updating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteCampaign deletes a campaign.
func (c *Core) DeleteCampaign(id int) error {
res, err := c.q.DeleteCampaign.Exec(id)

View file

@ -125,6 +125,7 @@ type Config struct {
OptinURL string
MessageURL string
ViewTrackURL string
ArchiveURL string
UnsubHeader bool
// Interval to scan the DB for active campaign checkpoints.
@ -462,6 +463,9 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
"MessageURL": func(msg *CampaignMessage) string {
return fmt.Sprintf(m.cfg.MessageURL, c.UUID, msg.Subscriber.UUID)
},
"ArchiveURL": func() string {
return m.cfg.ArchiveURL
},
}
for k, v := range m.tplFuncs {

View file

@ -17,6 +17,18 @@ func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
return err
}
// Add archive publishing field to campaigns.
if _, err := db.Exec(`ALTER TABLE campaigns
ADD COLUMN IF NOT EXISTS archive BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS archive_meta JSONB NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1
`); err != nil {
return err
}
// if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS "publish_meta" JSONB NOT NULL DEFAULT '{}'`); err != nil {
// return err
// }
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES

View file

@ -248,9 +248,13 @@ type Campaign struct {
Headers Headers `db:"headers" json:"headers"`
TemplateID int `db:"template_id" json:"template_id"`
Messenger string `db:"messenger" json:"messenger"`
Archive bool `db:"archive" json:"archive"`
ArchiveTemplateID int `db:"archive_template_id" json:"archive_template_id"`
ArchiveMeta json.RawMessage `db:"archive_meta" json:"archive_meta"`
// TemplateBody is joined in from templates by the next-campaigns query.
TemplateBody string `db:"template_body" json:"-"`
ArchiveTemplateBody string `db:"archive_template_body" json:"-"`
Tpl *template.Template `json:"-"`
SubjectTpl *txttpl.Template `json:"-"`
AltBodyTpl *template.Template `json:"-"`
@ -474,6 +478,26 @@ func (camps Campaigns) LoadStats(stmt *sqlx.Stmt) error {
// CompileTemplate compiles a campaign body template into its base
// template and sets the resultant template to Campaign.Tpl.
func (c *Campaign) CompileTemplate(f template.FuncMap) error {
// If the subject line has a template string, compile it.
if strings.Contains(c.Subject, "{{") {
subj := c.Subject
for _, r := range regTplFuncs {
subj = r.regExp.ReplaceAllString(subj, r.replace)
}
var txtFuncs map[string]interface{} = f
subjTpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(subj)
if err != nil {
return fmt.Errorf("error compiling subject: %v", err)
}
c.SubjectTpl = subjTpl
}
// No template or body. Nothing to compile.
if c.TemplateBody == "" || c.Body == "" {
return nil
}
// Compile the base template.
body := c.TemplateBody
for _, r := range regTplFuncs {
@ -511,21 +535,6 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
}
c.Tpl = out
// If the subject line has a template string, compile it.
if strings.Contains(c.Subject, "{{") {
subj := c.Subject
for _, r := range regTplFuncs {
subj = r.regExp.ReplaceAllString(subj, r.replace)
}
var txtFuncs map[string]interface{} = f
subjTpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(subj)
if err != nil {
return fmt.Errorf("error compiling subject: %v", err)
}
c.SubjectTpl = subjTpl
}
if strings.Contains(c.AltBody.String, "{{") {
b := c.AltBody.String
for _, r := range regTplFuncs {

View file

@ -61,6 +61,7 @@ type Queries struct {
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"`
GetArchivedCampaigns *sqlx.Stmt `query:"get-archived-campaigns"`
// These two queries are read as strings and based on settings.individual_tracking=on/off,
// are interpolated and copied to view and click counts. Same query, different tables.
@ -79,6 +80,7 @@ type Queries struct {
UpdateCampaign *sqlx.Stmt `query:"update-campaign"`
UpdateCampaignStatus *sqlx.Stmt `query:"update-campaign-status"`
UpdateCampaignCounts *sqlx.Stmt `query:"update-campaign-counts"`
UpdateCampaignArchive *sqlx.Stmt `query:"update-campaign-archive"`
RegisterCampaignView *sqlx.Stmt `query:"register-campaign-view"`
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`

View file

@ -473,8 +473,8 @@ counts AS (
AND subscribers.status='enabled'
),
camp AS (
INSERT INTO campaigns (uuid, type, name, subject, from_email, body, altbody, content_type, send_at, headers, tags, messenger, template_id, to_send, max_subscriber_id)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, (SELECT id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts)
INSERT INTO campaigns (uuid, type, name, subject, from_email, body, altbody, content_type, send_at, headers, tags, messenger, template_id, to_send, max_subscriber_id, archive, archive_template_id, archive_meta)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, (SELECT id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts), $15, $16, $17
RETURNING id
)
INSERT INTO campaign_lists (campaign_id, list_id, list_name)
@ -491,7 +491,7 @@ INSERT INTO campaign_lists (campaign_id, list_id, list_name)
SELECT c.id, c.uuid, c.name, c.subject, c.from_email,
c.messenger, c.started_at, c.to_send, c.sent, c.type,
c.body, c.altbody, c.send_at, c.headers, c.status, c.content_type, c.tags,
c.template_id, c.created_at, c.updated_at,
c.template_id, c.archive, c.archive_template_id, c.archive_meta, c.created_at, c.updated_at,
COUNT(*) OVER () AS total,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
@ -510,9 +510,17 @@ ORDER BY %s %s OFFSET $4 LIMIT (CASE WHEN $5 = 0 THEN NULL ELSE $5 END);
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)
LEFT JOIN templates ON (
CASE WHEN $3 = 'default' THEN templates.id = campaigns.template_id
ELSE templates.id = campaigns.archive_template_id END
)
WHERE CASE WHEN $1 > 0 THEN campaigns.id = $1 ELSE uuid = $2 END;
-- name: get-archived-campaigns
SELECT COUNT(*) OVER () AS total, id, uuid, subject, archive_meta, created_at FROM campaigns
WHERE archive=true AND type='regular' AND status=ANY('{running, paused, finished}')
ORDER by created_at DESC OFFSET $1 LIMIT $2;
-- name: get-campaign-stats
-- This query is used to lazy load campaign stats (views, counts, list of lists) given a list of campaign IDs.
-- The query returns results in the same order as the given campaign IDs, and for non-existent campaign IDs,
@ -748,6 +756,9 @@ WITH camp AS (
tags=$11::VARCHAR(100)[],
messenger=$12,
template_id=$13,
archive=$15,
archive_template_id=$16,
archive_meta=$17,
updated_at=NOW()
WHERE id = $1 RETURNING id
),
@ -770,6 +781,14 @@ WHERE id=$1;
-- name: update-campaign-status
UPDATE campaigns SET status=$2, updated_at=NOW() WHERE id = $1;
-- name: update-campaign-archive
UPDATE campaigns SET
archive=$2,
archive_template_id=(CASE WHEN $3 > 0 THEN $3 ELSE archive_template_id END),
archive_meta=(CASE WHEN $4::TEXT != '' THEN $4::JSONB ELSE archive_meta END),
updated_at=NOW()
WHERE id=$1;
-- name: delete-campaign
DELETE FROM campaigns WHERE id=$1;
@ -1009,3 +1028,4 @@ WITH sub AS (
SELECT id FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END
)
DELETE FROM bounces WHERE subscriber_id = (SELECT id FROM sub);

View file

@ -100,6 +100,11 @@ CREATE TABLE campaigns (
max_subscriber_id INT NOT NULL DEFAULT 0,
last_subscriber_id INT NOT NULL DEFAULT 0,
-- Publishing.
archive BOOLEAN NOT NULL DEFAULT false,
archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1,
archive_meta JSONB NOT NULL DEFAULT '{}',
started_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()

View file

@ -1,10 +1,10 @@
<!doctype html>
<html>
<head>
<title>{{ .Campaign.Subject }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<base target="_blank">
<style>
body {
background-color: #F0F1F3;

View file

@ -134,6 +134,20 @@ input[disabled] {
display: none;
}
.archive {
list-style-type: none;
margin: 0;
padding: 0;
}
.archive .date {
display: block;
color: #666;
font-size: 0.875em;
}
.archive li {
margin-bottom: 10px;
}
#btn-back {
display: none;
}

View file

@ -0,0 +1,21 @@
{{ define "archive" }}
{{ template "header" .}}
<section>
<h2>{{ L.T "public.archiveTitle" }}</h2>
<ul class="archive">
{{ range $c := .Data.Campaigns }}
<li>
<a href="{{ $c.URL }}">{{ $c.Subject }}</a>
<span class="date">{{ $c.CreatedAt.Time.Format "Mon, 02 Jan 2006" }}</span>
</li>
{{ end }}
</ul>
{{ if not .Data.Campaigns }}
{{ L.T "public.archiveEmpty" }}
{{ end }}
</section>
{{ template "footer" .}}
{{ end }}