diff --git a/cmd/archive.go b/cmd/archive.go new file mode 100644 index 00000000..bdb265c7 --- /dev/null +++ b/cmd/archive.go @@ -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 +} diff --git a/cmd/campaigns.go b/cmd/campaigns.go index d725969f..27dd10e9 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -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 } diff --git a/cmd/handlers.go b/cmd/handlers.go index 7e5eb017..c346f538 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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")) diff --git a/cmd/init.go b/cmd/init.go index 43ea3d25..8bfad4b0 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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"), diff --git a/cmd/install.go b/cmd/install.go index 42883a32..20c3cdb7 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -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) } diff --git a/cmd/manager_store.go b/cmd/manager_store.go index 42754299..a6f22f08 100644 --- a/cmd/manager_store.go +++ b/cmd/manager_store.go @@ -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 } diff --git a/cmd/public.go b/cmd/public.go index fa16c913..15c076d7 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -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"))) } diff --git a/frontend/cypress/e2e/archive.cy.js b/frontend/cypress/e2e/archive.cy.js new file mode 100644 index 00000000..be5c9360 --- /dev/null +++ b/frontend/cypress/e2e/archive.cy.js @@ -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'); + }); +}); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 112df6ec..f2421032 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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 }); diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue index 4d92a33f..a3ab7390 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -49,7 +49,7 @@ + value="campaign" icon="rocket-launch-outline">
@@ -172,7 +172,7 @@
- + + + +
+ + + + + + + + + + + + + + + + + {{ $t('globals.buttons.saveChanges') }} + + +
+
@@ -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; diff --git a/i18n/ca.json b/i18n/ca.json index a2c29f7c..254c2474 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -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ó", diff --git a/i18n/cs-cz.json b/i18n/cs-cz.json index 89c03f6e..6f2a0cea 100644 --- a/i18n/cs-cz.json +++ b/i18n/cs-cz.json @@ -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", diff --git a/i18n/de.json b/i18n/de.json index 242197f0..fb4b2036 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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", diff --git a/i18n/en.json b/i18n/en.json index 79f582e9..71ad471f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/i18n/es.json b/i18n/es.json index 7249398c..291303e1 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -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", diff --git a/i18n/fi.json b/i18n/fi.json index b6a0e8cc..721c7b4a 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -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", diff --git a/i18n/fr.json b/i18n/fr.json index 582bd664..6abd0bb3 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -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", diff --git a/i18n/hu.json b/i18n/hu.json index 8d0845ca..713c02de 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -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", diff --git a/i18n/it.json b/i18n/it.json index ca109c1c..d3d7e4c2 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -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", diff --git a/i18n/jp.json b/i18n/jp.json index ca32e732..1382df30 100644 --- a/i18n/jp.json +++ b/i18n/jp.json @@ -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": "サブスクリプション確認", diff --git a/i18n/ml.json b/i18n/ml.json index 787c1f50..3b44ec19 100644 --- a/i18n/ml.json +++ b/i18n/ml.json @@ -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": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക", diff --git a/i18n/nl.json b/i18n/nl.json index ffa4a130..5d5c5493 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -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", diff --git a/i18n/pl.json b/i18n/pl.json index a90b3a8e..f5097ef6 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -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ę", diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index 8215e7c5..b625c7a6 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -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", diff --git a/i18n/pt.json b/i18n/pt.json index 67dfe5eb..043b1b58 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -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", diff --git a/i18n/ro.json b/i18n/ro.json index a47af861..b6cda422 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -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", diff --git a/i18n/ru.json b/i18n/ru.json index 6479bccf..719f5a64 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -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": "Подтверждение подписки", diff --git a/i18n/tr.json b/i18n/tr.json index e1be41dd..4974409f 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -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", diff --git a/i18n/vi.json b/i18n/vi.json index 4501e32d..97e05618 100644 --- a/i18n/vi.json +++ b/i18n/vi.json @@ -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ý", diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index b9ab46f9..10d3d8d2 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -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": "确认订阅", diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index 869b58b1..07b2463b 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -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": "確認訂閱", diff --git a/internal/core/campaigns.go b/internal/core/campaigns.go index 9254e818..c896a782 100644 --- a/internal/core/campaigns.go +++ b/internal/core/campaigns.go @@ -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) diff --git a/internal/manager/manager.go b/internal/manager/manager.go index e819be9f..aea79a60 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -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 { diff --git a/internal/migrations/v2.3.0.go b/internal/migrations/v2.3.0.go index 9c950c55..b5f4a3ee 100644 --- a/internal/migrations/v2.3.0.go +++ b/internal/migrations/v2.3.0.go @@ -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 diff --git a/models/models.go b/models/models.go index 166f56b4..6950d65b 100644 --- a/models/models.go +++ b/models/models.go @@ -234,26 +234,30 @@ type Campaign struct { Base CampaignMeta - UUID string `db:"uuid" json:"uuid"` - Type string `db:"type" json:"type"` - 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"` - AltBody null.String `db:"altbody" json:"altbody"` - 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"` - Headers Headers `db:"headers" json:"headers"` - TemplateID int `db:"template_id" json:"template_id"` - Messenger string `db:"messenger" json:"messenger"` + UUID string `db:"uuid" json:"uuid"` + Type string `db:"type" json:"type"` + 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"` + AltBody null.String `db:"altbody" json:"altbody"` + 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"` + 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:"-"` - Tpl *template.Template `json:"-"` - SubjectTpl *txttpl.Template `json:"-"` - AltBodyTpl *template.Template `json:"-"` + 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:"-"` // Pseudofield for getting the total number of subscribers // in searches and queries. @@ -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 { diff --git a/models/queries.go b/models/queries.go index 8521af83..06876402 100644 --- a/models/queries.go +++ b/models/queries.go @@ -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"` diff --git a/queries.sql b/queries.sql index 750e9058..8b20a2f5 100644 --- a/queries.sql +++ b/queries.sql @@ -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); + diff --git a/schema.sql b/schema.sql index 70fb1ee3..f005a568 100644 --- a/schema.sql +++ b/schema.sql @@ -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() diff --git a/static/email-templates/default.tpl b/static/email-templates/default.tpl index e6697f51..5423d561 100644 --- a/static/email-templates/default.tpl +++ b/static/email-templates/default.tpl @@ -1,10 +1,10 @@ + {{ .Campaign.Subject }} -