@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
BounceTypeHard = "hard"
|
||||
BounceTypeSoft = "soft"
|
||||
BounceTypeComplaint = "complaint"
|
||||
)
|
||||
|
||||
// Bounce represents a single bounce event.
|
||||
type Bounce struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Source string `db:"source" json:"source"`
|
||||
Meta json.RawMessage `db:"meta" json:"meta"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
||||
// One of these should be provided.
|
||||
Email string `db:"email" json:"email,omitempty"`
|
||||
SubscriberUUID string `db:"subscriber_uuid" json:"subscriber_uuid,omitempty"`
|
||||
SubscriberID int `db:"subscriber_id" json:"subscriber_id,omitempty"`
|
||||
SubscriberStatus string `db:"subscriber_status" json:"subscriber_status"`
|
||||
|
||||
CampaignUUID string `db:"campaign_uuid" json:"campaign_uuid,omitempty"`
|
||||
Campaign *json.RawMessage `db:"campaign" json:"campaign"`
|
||||
|
||||
// Pseudofield for getting the total number of bounces
|
||||
// in searches and queries.
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
txttpl "text/template"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/lib/pq"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
const (
|
||||
CampaignStatusDraft = "draft"
|
||||
CampaignStatusScheduled = "scheduled"
|
||||
CampaignStatusRunning = "running"
|
||||
CampaignStatusPaused = "paused"
|
||||
CampaignStatusFinished = "finished"
|
||||
CampaignStatusCancelled = "cancelled"
|
||||
CampaignTypeRegular = "regular"
|
||||
CampaignTypeOptin = "optin"
|
||||
CampaignContentTypeRichtext = "richtext"
|
||||
CampaignContentTypeHTML = "html"
|
||||
CampaignContentTypeMarkdown = "markdown"
|
||||
CampaignContentTypePlain = "plain"
|
||||
CampaignContentTypeVisual = "visual"
|
||||
)
|
||||
|
||||
// Campaigns represents a slice of Campaigns.
|
||||
type Campaigns []Campaign
|
||||
|
||||
// Campaign represents an e-mail campaign.
|
||||
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"`
|
||||
BodySource null.String `db:"body_source" json:"body_source"`
|
||||
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"`
|
||||
Attribs JSON `db:"attribs" json:"attribs"`
|
||||
TemplateID null.Int `db:"template_id" json:"template_id"`
|
||||
Messenger string `db:"messenger" json:"messenger"`
|
||||
Archive bool `db:"archive" json:"archive"`
|
||||
ArchiveSlug null.String `db:"archive_slug" json:"archive_slug"`
|
||||
ArchiveTemplateID null.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:"-"`
|
||||
|
||||
// HeaderTpls is holds optionally {{ templated }} campaign headers.
|
||||
HeaderTpls []map[string]*txttpl.Template `json:"-"`
|
||||
|
||||
// List of media (attachment) IDs obtained from the next-campaign query
|
||||
// while sending a campaign.
|
||||
MediaIDs pq.Int64Array `json:"-" db:"media_id"`
|
||||
|
||||
// Fetched bodies of the attachments.
|
||||
Attachments []Attachment `json:"-" db:"-"`
|
||||
|
||||
// Pseudofield for getting the total number of subscribers
|
||||
// in searches and queries.
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
// CampaignMeta contains fields tracking a campaign's progress.
|
||||
type CampaignMeta struct {
|
||||
CampaignID int `db:"campaign_id" json:"-"`
|
||||
Views int `db:"views" json:"views"`
|
||||
Clicks int `db:"clicks" json:"clicks"`
|
||||
Bounces int `db:"bounces" json:"bounces"`
|
||||
|
||||
// This is a list of {list_id, name} pairs unlike Subscriber.Lists[]
|
||||
// because lists can be deleted after a campaign is finished, resulting
|
||||
// in null lists data to be returned. For that reason, campaign_lists maintains
|
||||
// campaign-list associations with a historical record of id + name that persist
|
||||
// even after a list is deleted.
|
||||
Lists types.JSONText `db:"lists" json:"lists"`
|
||||
Media types.JSONText `db:"media" json:"media"`
|
||||
|
||||
StartedAt null.Time `db:"started_at" json:"started_at"`
|
||||
ToSend int `db:"to_send" json:"to_send"`
|
||||
Sent int `db:"sent" json:"sent"`
|
||||
}
|
||||
|
||||
// GetIDs returns the list of campaign IDs.
|
||||
func (camps Campaigns) GetIDs() []int {
|
||||
IDs := make([]int, len(camps))
|
||||
for i, c := range camps {
|
||||
IDs[i] = c.ID
|
||||
}
|
||||
|
||||
return IDs
|
||||
}
|
||||
|
||||
// LoadStats lazy loads campaign stats onto a list of campaigns.
|
||||
func (camps Campaigns) LoadStats(stmt *sqlx.Stmt) error {
|
||||
var meta []CampaignMeta
|
||||
if err := stmt.Select(&meta, pq.Array(camps.GetIDs())); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(camps) != len(meta) {
|
||||
return errors.New("campaign stats count does not match")
|
||||
}
|
||||
|
||||
for i, c := range meta {
|
||||
if c.CampaignID == camps[i].ID {
|
||||
camps[i].Lists = c.Lists
|
||||
camps[i].Views = c.Views
|
||||
camps[i].Clicks = c.Clicks
|
||||
camps[i].Bounces = c.Bounces
|
||||
camps[i].Media = c.Media
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 hasTplExpr(c.Subject) {
|
||||
subj := c.Subject
|
||||
for _, r := range regTplFuncs {
|
||||
subj = r.regExp.ReplaceAllString(subj, r.replace)
|
||||
}
|
||||
|
||||
var txtFuncs map[string]any = f
|
||||
subjTpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(subj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling subject: %v", err)
|
||||
}
|
||||
c.SubjectTpl = subjTpl
|
||||
}
|
||||
|
||||
// Compile the base template.
|
||||
body := c.TemplateBody
|
||||
|
||||
if body == "" || c.ContentType == CampaignContentTypeVisual {
|
||||
body = `{{ template "content" . }}`
|
||||
}
|
||||
|
||||
for _, r := range regTplFuncs {
|
||||
body = r.regExp.ReplaceAllString(body, r.replace)
|
||||
}
|
||||
|
||||
baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling base template: %v", err)
|
||||
}
|
||||
|
||||
// If the format is markdown, convert Markdown to HTML.
|
||||
if c.ContentType == CampaignContentTypeMarkdown {
|
||||
var b bytes.Buffer
|
||||
if err := markdown.Convert([]byte(c.Body), &b); err != nil {
|
||||
return err
|
||||
}
|
||||
body = b.String()
|
||||
} else {
|
||||
body = c.Body
|
||||
}
|
||||
|
||||
// Compile the campaign message.
|
||||
for _, r := range regTplFuncs {
|
||||
body = r.regExp.ReplaceAllString(body, r.replace)
|
||||
}
|
||||
|
||||
msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling message: %v", err)
|
||||
}
|
||||
|
||||
out, err := baseTPL.AddParseTree(ContentTpl, msgTpl.Tree)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error inserting child template: %v", err)
|
||||
}
|
||||
c.Tpl = out
|
||||
|
||||
if hasTplExpr(c.AltBody.String) {
|
||||
b := c.AltBody.String
|
||||
for _, r := range regTplFuncs {
|
||||
b = r.regExp.ReplaceAllString(b, r.replace)
|
||||
}
|
||||
bTpl, err := template.New(ContentTpl).Funcs(f).Parse(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling alt plaintext message: %v", err)
|
||||
}
|
||||
c.AltBodyTpl = bTpl
|
||||
}
|
||||
|
||||
// Compile any header values that contain template expressions.
|
||||
for _, set := range c.Headers {
|
||||
for _, val := range set {
|
||||
if hasTplExpr(val) {
|
||||
c.HeaderTpls = make([]map[string]*txttpl.Template, len(c.Headers))
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.HeaderTpls != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.HeaderTpls != nil {
|
||||
var txtFuncs map[string]any = f
|
||||
for i, set := range c.Headers {
|
||||
c.HeaderTpls[i] = make(map[string]*txttpl.Template, len(set))
|
||||
for hdr, val := range set {
|
||||
if !hasTplExpr(val) {
|
||||
continue
|
||||
}
|
||||
tpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling header %q: %v", hdr, err)
|
||||
}
|
||||
c.HeaderTpls[i][hdr] = tpl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasTplExpr checks whether a given string has a Go template expression with {{ and }}.
|
||||
func hasTplExpr(s string) bool {
|
||||
_, after, ok := strings.Cut(s, "{{")
|
||||
return ok && strings.Contains(after, "}}")
|
||||
}
|
||||
|
||||
// ConvertContent converts a campaign's body from one format to another,
|
||||
// for example, Markdown to HTML.
|
||||
func (c *Campaign) ConvertContent(from, to string) (string, error) {
|
||||
body := c.Body
|
||||
for _, r := range regTplFuncs {
|
||||
body = r.regExp.ReplaceAllString(body, r.replace)
|
||||
}
|
||||
|
||||
// If the format is markdown, convert Markdown to HTML.
|
||||
var out string
|
||||
if from == CampaignContentTypeMarkdown &&
|
||||
(to == CampaignContentTypeHTML || to == CampaignContentTypeRichtext) {
|
||||
var b bytes.Buffer
|
||||
if err := markdown.Convert([]byte(c.Body), &b); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = b.String()
|
||||
} else {
|
||||
return out, errors.New("unknown formats to convert")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// Enum values for various statuses.
|
||||
const (
|
||||
// Headers attached to e-mails for bounce tracking.
|
||||
EmailHeaderSubscriberUUID = "X-EagleCast-Subscriber"
|
||||
EmailHeaderCampaignUUID = "X-EagleCast-Campaign"
|
||||
|
||||
// Standard e-mail headers.
|
||||
EmailHeaderDate = "Date"
|
||||
EmailHeaderFrom = "From"
|
||||
EmailHeaderSubject = "Subject"
|
||||
EmailHeaderMessageId = "Message-Id"
|
||||
EmailHeaderDeliveredTo = "Delivered-To"
|
||||
EmailHeaderReceived = "Received"
|
||||
|
||||
// TwoFA types.
|
||||
TwofaTypeNone = "none"
|
||||
TwofaTypeTOTP = "totp"
|
||||
)
|
||||
|
||||
// regTplFunc represents contains a regular expression for wrapping and
|
||||
// substituting a Go template function from the user's shorthand to a full
|
||||
// function call.
|
||||
type regTplFunc struct {
|
||||
regExp *regexp.Regexp
|
||||
replace string
|
||||
}
|
||||
|
||||
var regTplFuncs = []regTplFunc{
|
||||
// Regular expression for matching {{ TrackLink "http://link.com" }} in the template
|
||||
// and substituting it with {{ TrackLink "http://link.com" . }} (the dot context)
|
||||
// before compilation. This is to make linking easier for users.
|
||||
{
|
||||
regExp: regexp.MustCompile(`{{\s*TrackLink\s+"([^"]+)"\s*}}`),
|
||||
replace: `{{ TrackLink "$1" . }}`,
|
||||
},
|
||||
|
||||
// Convert the shorthand https://google.com@TrackLink to {{ TrackLink ... }}.
|
||||
// This is for WYSIWYG editors that encode and break quotes {{ "" }} when inserted
|
||||
// inside <a href="{{ TrackLink "https://these-quotes-break" }}>.
|
||||
// The regex matches all characters that may occur in an URL
|
||||
// (see "2. Characters" in RFC3986: https://www.ietf.org/rfc/rfc3986.txt)
|
||||
{
|
||||
regExp: regexp.MustCompile(`(https?://[\p{L}\p{N}_\-\.~!#$&'()*+,/:;=?@\[\]%]*)@TrackLink`),
|
||||
replace: `{{ TrackLink "$1" . }}`,
|
||||
},
|
||||
|
||||
{
|
||||
regExp: regexp.MustCompile(`{{(\s+)?(TrackView|UnsubscribeURL|ManageURL|OptinURL|MessageURL)(\s+)?}}`),
|
||||
replace: `{{ $2 . }}`,
|
||||
},
|
||||
}
|
||||
|
||||
// markdown is a global instance of Markdown parser and renderer.
|
||||
var markdown = goldmark.New(
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithXHTML(),
|
||||
html.WithUnsafe(),
|
||||
),
|
||||
goldmark.WithExtensions(
|
||||
extension.Table,
|
||||
extension.Strikethrough,
|
||||
extension.TaskList,
|
||||
extension.NewTypographer(
|
||||
extension.WithTypographicSubstitutions(extension.TypographicSubstitutions{
|
||||
extension.LeftDoubleQuote: []byte(`"`),
|
||||
extension.RightDoubleQuote: []byte(`"`),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Headers represents an array of string maps used to represent SMTP, HTTP headers etc.
|
||||
// similar to url.Values{}
|
||||
type Headers []map[string]string
|
||||
|
||||
// PageResults is a generic HTTP response container for paginated results of list of items.
|
||||
type PageResults struct {
|
||||
Results any `json:"results"`
|
||||
|
||||
Search string `json:"search"`
|
||||
Query string `json:"query"`
|
||||
Total int `json:"total"`
|
||||
PerPage int `json:"per_page"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// Base holds common fields shared across models.
|
||||
type Base struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt null.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// JSON is the wrapper for reading and writing arbitrary JSONB fields from the DB.
|
||||
type JSON map[string]any
|
||||
|
||||
// StringIntMap is used to define DB Scan()s.
|
||||
type StringIntMap map[string]int
|
||||
|
||||
// Value returns the JSON marshalled SubscriberAttribs.
|
||||
func (s JSON) Value() (driver.Value, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
// Scan unmarshals JSONB from the DB.
|
||||
func (s JSON) Scan(b any) error {
|
||||
if b == nil {
|
||||
s = make(JSON)
|
||||
return nil
|
||||
}
|
||||
|
||||
if data, ok := b.([]byte); ok {
|
||||
return json.Unmarshal(data, &s)
|
||||
}
|
||||
return fmt.Errorf("could not not decode type %T -> %T", b, s)
|
||||
}
|
||||
|
||||
// Scan unmarshals JSONB from the DB.
|
||||
func (s StringIntMap) Scan(src any) error {
|
||||
if src == nil {
|
||||
s = make(StringIntMap)
|
||||
return nil
|
||||
}
|
||||
|
||||
if data, ok := src.([]byte); ok {
|
||||
return json.Unmarshal(data, &s)
|
||||
}
|
||||
return fmt.Errorf("could not not decode type %T -> %T", src, s)
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface.
|
||||
func (h *Headers) Scan(src any) error {
|
||||
var b []byte
|
||||
switch src := src.(type) {
|
||||
case []byte:
|
||||
b = src
|
||||
case string:
|
||||
b = []byte(src)
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(b, h); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface.
|
||||
func (h Headers) Value() (driver.Value, error) {
|
||||
if h == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if n := len(h); n > 0 {
|
||||
b, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
return "[]", nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/lib/pq"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
const (
|
||||
ListTypePrivate = "private"
|
||||
ListTypePublic = "public"
|
||||
ListOptinSingle = "single"
|
||||
ListOptinDouble = "double"
|
||||
ListStatusActive = "active"
|
||||
ListStatusArchived = "archived"
|
||||
)
|
||||
|
||||
// List represents a mailing list.
|
||||
type List struct {
|
||||
Base
|
||||
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Optin string `db:"optin" json:"optin"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Tags pq.StringArray `db:"tags" json:"tags"`
|
||||
Description string `db:"description" json:"description"`
|
||||
SubscriberCount int `db:"subscriber_count" json:"subscriber_count"`
|
||||
SubscriberCounts StringIntMap `db:"subscriber_statuses" json:"subscriber_statuses"`
|
||||
SubscriberID int `db:"subscriber_id" json:"-"`
|
||||
|
||||
// This is only relevant when querying the lists of a subscriber.
|
||||
SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"`
|
||||
SubscriptionCreatedAt null.Time `db:"subscription_created_at" json:"subscription_created_at,omitempty"`
|
||||
SubscriptionUpdatedAt null.Time `db:"subscription_updated_at" json:"subscription_updated_at,omitempty"`
|
||||
|
||||
// Pseudofield for getting the total number of subscribers
|
||||
// in searches and queries.
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/textproto"
|
||||
txttpl "text/template"
|
||||
)
|
||||
|
||||
// Message is the message pushed to a Messenger.
|
||||
type Message struct {
|
||||
From string
|
||||
To []string
|
||||
Subject string
|
||||
ContentType string
|
||||
Body []byte
|
||||
AltBody []byte
|
||||
Headers textproto.MIMEHeader
|
||||
Attachments []Attachment
|
||||
|
||||
Subscriber Subscriber
|
||||
|
||||
// Campaign is generally the same instance for a large number of subscribers.
|
||||
Campaign *Campaign
|
||||
|
||||
// Messenger is the messenger backend to use: email|postback.
|
||||
Messenger string
|
||||
}
|
||||
|
||||
// Attachment represents a file or blob attachment that can be
|
||||
// sent along with a message by a Messenger.
|
||||
type Attachment struct {
|
||||
Name string
|
||||
Header textproto.MIMEHeader
|
||||
Content []byte
|
||||
|
||||
// IsInline marks the attachment as an inline (CID) part
|
||||
// to be wrapped in multipart/related.
|
||||
IsInline bool
|
||||
}
|
||||
|
||||
// TxMessage subscriber modes.
|
||||
const (
|
||||
TxSubModeDefault = "default"
|
||||
TxSubModeFallback = "fallback"
|
||||
TxSubModeExternal = "external"
|
||||
)
|
||||
|
||||
// TxMessage represents an e-mail campaign.
|
||||
type TxMessage struct {
|
||||
SubscriberMode string `json:"subscriber_mode"`
|
||||
SubscriberEmails []string `json:"subscriber_emails"`
|
||||
SubscriberIDs []int `json:"subscriber_ids"`
|
||||
|
||||
// Deprecated.
|
||||
SubscriberEmail string `json:"subscriber_email"`
|
||||
SubscriberID int `json:"subscriber_id"`
|
||||
|
||||
TemplateID int `json:"template_id"`
|
||||
Data map[string]any `json:"data"`
|
||||
FromEmail string `json:"from_email"`
|
||||
Headers Headers `json:"headers"`
|
||||
ContentType string `json:"content_type"`
|
||||
Messenger string `json:"messenger"`
|
||||
Subject string `json:"subject"`
|
||||
AltBody string `json:"altbody"`
|
||||
|
||||
// File attachments added from multi-part form data.
|
||||
Attachments []Attachment `json:"-"`
|
||||
|
||||
Body []byte `json:"-"`
|
||||
Tpl *template.Template `json:"-"`
|
||||
SubjectTpl *txttpl.Template `json:"-"`
|
||||
}
|
||||
|
||||
func (m *TxMessage) Render(sub Subscriber, tpl *Template, funcs txttpl.FuncMap) error {
|
||||
data := struct {
|
||||
Subscriber Subscriber
|
||||
Tx *TxMessage
|
||||
}{sub, m}
|
||||
|
||||
// Render the body.
|
||||
b := bytes.Buffer{}
|
||||
if err := tpl.Tpl.ExecuteTemplate(&b, BaseTpl, data); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Body = make([]byte, b.Len())
|
||||
copy(m.Body, b.Bytes())
|
||||
b.Reset()
|
||||
|
||||
// Render alt body if it has any templating strings.
|
||||
if m.AltBody != "" && hasTplExpr(m.AltBody) {
|
||||
t, err := txttpl.New(BaseTpl).Funcs(funcs).Parse(m.AltBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling alt body: %v", err)
|
||||
}
|
||||
if err := t.ExecuteTemplate(&b, BaseTpl, data); err != nil {
|
||||
return err
|
||||
}
|
||||
m.AltBody = b.String()
|
||||
b.Reset()
|
||||
}
|
||||
|
||||
// Was a subject provided in the message?
|
||||
var (
|
||||
subjTpl *txttpl.Template
|
||||
subject = m.Subject
|
||||
)
|
||||
if subject != "" {
|
||||
if hasTplExpr(m.Subject) {
|
||||
// If the subject has a template string, render that.
|
||||
s, err := txttpl.New(BaseTpl).Funcs(funcs).Parse(m.Subject)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling subject: %v", err)
|
||||
}
|
||||
subjTpl = s
|
||||
}
|
||||
} else {
|
||||
// Use the subject from the template.
|
||||
subject = tpl.Subject
|
||||
subjTpl = tpl.SubjectTpl
|
||||
}
|
||||
|
||||
// If the subject is also a template, render that.
|
||||
if subjTpl != nil {
|
||||
if err := subjTpl.ExecuteTemplate(&b, BaseTpl, data); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Subject = b.String()
|
||||
b.Reset()
|
||||
} else {
|
||||
m.Subject = subject
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Queries contains all prepared SQL queries.
|
||||
type Queries struct {
|
||||
GetDashboardCharts *sqlx.Stmt `query:"get-dashboard-charts"`
|
||||
GetDashboardCounts *sqlx.Stmt `query:"get-dashboard-counts"`
|
||||
|
||||
InsertSubscriber *sqlx.Stmt `query:"insert-subscriber"`
|
||||
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`
|
||||
UpsertBlocklistSubscriber *sqlx.Stmt `query:"upsert-blocklist-subscriber"`
|
||||
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
|
||||
HasSubscriberLists *sqlx.Stmt `query:"has-subscriber-list"`
|
||||
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
|
||||
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
|
||||
GetSubscriptions *sqlx.Stmt `query:"get-subscriptions"`
|
||||
GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"`
|
||||
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
|
||||
UpdateSubscriberWithLists *sqlx.Stmt `query:"update-subscriber-with-lists"`
|
||||
BlocklistSubscribers *sqlx.Stmt `query:"blocklist-subscribers"`
|
||||
AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"`
|
||||
DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"`
|
||||
DeleteUnconfirmedSubscriptions *sqlx.Stmt `query:"delete-unconfirmed-subscriptions"`
|
||||
ConfirmSubscriptionOptin *sqlx.Stmt `query:"confirm-subscription-optin"`
|
||||
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
|
||||
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
|
||||
DeleteBlocklistedSubscribers *sqlx.Stmt `query:"delete-blocklisted-subscribers"`
|
||||
DeleteOrphanSubscribers *sqlx.Stmt `query:"delete-orphan-subscribers"`
|
||||
UnsubscribeByCampaign *sqlx.Stmt `query:"unsubscribe-by-campaign"`
|
||||
ExportSubscriberData *sqlx.Stmt `query:"export-subscriber-data"`
|
||||
GetSubscriberActivity *sqlx.Stmt `query:"get-subscriber-activity"`
|
||||
|
||||
// Non-prepared arbitrary subscriber queries.
|
||||
QuerySubscribers string `query:"query-subscribers"`
|
||||
QuerySubscribersCount string `query:"query-subscribers-count"`
|
||||
QuerySubscribersCountAll *sqlx.Stmt `query:"query-subscribers-count-all"`
|
||||
QuerySubscribersForExport string `query:"query-subscribers-for-export"`
|
||||
QuerySubscribersTpl string `query:"query-subscribers-template"`
|
||||
DeleteSubscribersByQuery string `query:"delete-subscribers-by-query"`
|
||||
AddSubscribersToListsByQuery string `query:"add-subscribers-to-lists-by-query"`
|
||||
BlocklistSubscribersByQuery string `query:"blocklist-subscribers-by-query"`
|
||||
DeleteSubscriptionsByQuery string `query:"delete-subscriptions-by-query"`
|
||||
UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"`
|
||||
|
||||
CreateList *sqlx.Stmt `query:"create-list"`
|
||||
QueryLists string `query:"query-lists"`
|
||||
GetLists *sqlx.Stmt `query:"get-lists"`
|
||||
GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
|
||||
GetListTypes *sqlx.Stmt `query:"get-list-types"`
|
||||
UpdateList *sqlx.Stmt `query:"update-list"`
|
||||
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
|
||||
DeleteLists *sqlx.Stmt `query:"delete-lists"`
|
||||
|
||||
CreateCampaign *sqlx.Stmt `query:"create-campaign"`
|
||||
QueryCampaigns string `query:"query-campaigns"`
|
||||
GetCampaign *sqlx.Stmt `query:"get-campaign"`
|
||||
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"`
|
||||
CampaignHasLists *sqlx.Stmt `query:"campaign-has-lists"`
|
||||
|
||||
// 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.
|
||||
GetCampaignAnalyticsCounts string `query:"get-campaign-analytics-counts"`
|
||||
GetCampaignViewCounts *sqlx.Stmt `query:"get-campaign-view-counts"`
|
||||
GetCampaignClickCounts *sqlx.Stmt `query:"get-campaign-click-counts"`
|
||||
GetCampaignLinkCounts *sqlx.Stmt `query:"get-campaign-link-counts"`
|
||||
GetCampaignBounceCounts *sqlx.Stmt `query:"get-campaign-bounce-counts"`
|
||||
DeleteCampaignViews *sqlx.Stmt `query:"delete-campaign-views"`
|
||||
DeleteCampaignLinkClicks *sqlx.Stmt `query:"delete-campaign-link-clicks"`
|
||||
ExportCampaignViews *sqlx.Stmt `query:"export-campaign-views"`
|
||||
ExportCampaignLinkClicks *sqlx.Stmt `query:"export-campaign-link-clicks"`
|
||||
|
||||
NextCampaigns *sqlx.Stmt `query:"next-campaigns"`
|
||||
GetRunningCampaign *sqlx.Stmt `query:"get-running-campaign"`
|
||||
NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"`
|
||||
GetOneCampaignSubscriber *sqlx.Stmt `query:"get-one-campaign-subscriber"`
|
||||
UpdateCampaign *sqlx.Stmt `query:"update-campaign"`
|
||||
UpdateCampaignStatus *sqlx.Stmt `query:"update-campaign-status"`
|
||||
UpdateCampaignCounts *sqlx.Stmt `query:"update-campaign-counts"`
|
||||
UpdateCampaignArchive *sqlx.Stmt `query:"update-campaign-archive"`
|
||||
RegisterCampaignView *sqlx.Stmt `query:"register-campaign-view"`
|
||||
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`
|
||||
DeleteCampaigns *sqlx.Stmt `query:"delete-campaigns"`
|
||||
|
||||
InsertMedia *sqlx.Stmt `query:"insert-media"`
|
||||
GetMedia *sqlx.Stmt `query:"get-media"`
|
||||
QueryMedia *sqlx.Stmt `query:"query-media"`
|
||||
DeleteMedia *sqlx.Stmt `query:"delete-media"`
|
||||
|
||||
CreateTemplate *sqlx.Stmt `query:"create-template"`
|
||||
GetTemplates *sqlx.Stmt `query:"get-templates"`
|
||||
UpdateTemplate *sqlx.Stmt `query:"update-template"`
|
||||
SetDefaultTemplate *sqlx.Stmt `query:"set-default-template"`
|
||||
DeleteTemplate *sqlx.Stmt `query:"delete-template"`
|
||||
|
||||
CreateLink *sqlx.Stmt `query:"create-link"`
|
||||
GetLinkURL *sqlx.Stmt `query:"get-link-url"`
|
||||
RegisterLinkClick *sqlx.Stmt `query:"register-link-click"`
|
||||
|
||||
GetSettings *sqlx.Stmt `query:"get-settings"`
|
||||
UpdateSettings *sqlx.Stmt `query:"update-settings"`
|
||||
UpdateSettingsByKey *sqlx.Stmt `query:"update-settings-by-key"`
|
||||
|
||||
// GetStats *sqlx.Stmt `query:"get-stats"`
|
||||
RecordBounce *sqlx.Stmt `query:"record-bounce"`
|
||||
QueryBounces string `query:"query-bounces"`
|
||||
BlocklistBouncedSubscribers *sqlx.Stmt `query:"blocklist-bounced-subscribers"`
|
||||
DeleteBounces *sqlx.Stmt `query:"delete-bounces"`
|
||||
DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
|
||||
GetDBInfo string `query:"get-db-info"`
|
||||
|
||||
CreateUser *sqlx.Stmt `query:"create-user"`
|
||||
UpdateUser *sqlx.Stmt `query:"update-user"`
|
||||
UpdateUserProfile *sqlx.Stmt `query:"update-user-profile"`
|
||||
UpdateUserLogin *sqlx.Stmt `query:"update-user-login"`
|
||||
SetUserTwoFA *sqlx.Stmt `query:"set-user-twofa"`
|
||||
DeleteUsers *sqlx.Stmt `query:"delete-users"`
|
||||
GetUsers *sqlx.Stmt `query:"get-users"`
|
||||
GetUser *sqlx.Stmt `query:"get-user"`
|
||||
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
|
||||
LoginUser *sqlx.Stmt `query:"login-user"`
|
||||
DeleteUserSessions *sqlx.Stmt `query:"delete-user-sessions"`
|
||||
|
||||
CreateRole *sqlx.Stmt `query:"create-role"`
|
||||
GetUserRoles *sqlx.Stmt `query:"get-user-roles"`
|
||||
GetListRoles *sqlx.Stmt `query:"get-list-roles"`
|
||||
UpdateRole *sqlx.Stmt `query:"update-role"`
|
||||
DeleteRole *sqlx.Stmt `query:"delete-role"`
|
||||
UpsertListPermissions *sqlx.Stmt `query:"upsert-list-permissions"`
|
||||
DeleteListPermission *sqlx.Stmt `query:"delete-list-permission"`
|
||||
}
|
||||
|
||||
// compileSubscriberQueryTpl takes an arbitrary WHERE expressions
|
||||
// to filter subscribers from the subscribers table and prepares a query
|
||||
// out of it using the raw `query-subscribers-template` query template.
|
||||
// While doing this, a readonly transaction is created and the query is
|
||||
// dry run on it to ensure that it is indeed readonly.
|
||||
func (q *Queries) compileSubscriberQueryTpl(searchStr, queryExp string, db *sqlx.DB, subStatus string) (string, error) {
|
||||
tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// There's an arbitrary query condition.
|
||||
cond := "TRUE"
|
||||
if queryExp != "" {
|
||||
cond = queryExp
|
||||
}
|
||||
|
||||
// Perform the dry run.
|
||||
stmt := strings.ReplaceAll(q.QuerySubscribersTpl, "%query%", cond)
|
||||
if _, err := tx.Exec(stmt, true, pq.Int64Array{}, subStatus, searchStr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// compileSubscriberQueryTpl takes an arbitrary WHERE expressions and a subscriber
|
||||
// query template that depends on the filter (eg: delete by query, blocklist by query etc.)
|
||||
// combines and executes them.
|
||||
func (q *Queries) ExecSubQueryTpl(searchStr, queryExp, baseQueryTpl string, listIDs []int, db *sqlx.DB, subStatus string, args ...any) error {
|
||||
// Perform a dry run.
|
||||
filterExp, err := q.compileSubscriberQueryTpl(searchStr, queryExp, db, subStatus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(listIDs) == 0 {
|
||||
listIDs = []int{}
|
||||
}
|
||||
|
||||
// Insert the subscriber filter query into the target query.
|
||||
stmt := strings.ReplaceAll(baseQueryTpl, "%query%", filterExp)
|
||||
|
||||
// First argument is the boolean indicating if the query is a dry run.
|
||||
a := append([]any{false, pq.Array(listIDs), subStatus, searchStr}, args...)
|
||||
|
||||
// Execute the query on the DB.
|
||||
if _, err := db.Exec(stmt, a...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package models
|
||||
|
||||
import "gopkg.in/volatiletech/null.v6"
|
||||
|
||||
// Settings represents the app settings stored in the DB.
|
||||
type Settings struct {
|
||||
AppSiteName string `json:"app.site_name"`
|
||||
AppRootURL string `json:"app.root_url"`
|
||||
AppLogoURL string `json:"app.logo_url"`
|
||||
AppFaviconURL string `json:"app.favicon_url"`
|
||||
AppFromEmail string `json:"app.from_email"`
|
||||
AppNotifyEmails []string `json:"app.notify_emails"`
|
||||
EnablePublicSubPage bool `json:"app.enable_public_subscription_page"`
|
||||
EnablePublicArchive bool `json:"app.enable_public_archive"`
|
||||
EnablePublicArchiveRSSContent bool `json:"app.enable_public_archive_rss_content"`
|
||||
ShowOptinPage bool `json:"app.show_optin_page"`
|
||||
SendOptinConfirmation bool `json:"app.send_optin_confirmation"`
|
||||
AppLang string `json:"app.lang"`
|
||||
|
||||
AppBatchSize int `json:"app.batch_size"`
|
||||
AppConcurrency int `json:"app.concurrency"`
|
||||
AppMaxSendErrors int `json:"app.max_send_errors"`
|
||||
AppMessageRate int `json:"app.message_rate"`
|
||||
CacheSlowQueries bool `json:"app.cache_slow_queries"`
|
||||
CacheSlowQueriesInterval string `json:"app.cache_slow_queries_interval"`
|
||||
|
||||
AppMessageSlidingWindow bool `json:"app.message_sliding_window"`
|
||||
AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"`
|
||||
AppMessageSlidingWindowRate int `json:"app.message_sliding_window_rate"`
|
||||
|
||||
PrivacyIndividualTracking bool `json:"privacy.individual_tracking"`
|
||||
PrivacyDisableTracking bool `json:"privacy.disable_tracking"`
|
||||
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
|
||||
PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"`
|
||||
PrivacyAllowPreferences bool `json:"privacy.allow_preferences"`
|
||||
PrivacyAllowExport bool `json:"privacy.allow_export"`
|
||||
PrivacyAllowWipe bool `json:"privacy.allow_wipe"`
|
||||
PrivacyExportable []string `json:"privacy.exportable"`
|
||||
PrivacyRecordOptinIP bool `json:"privacy.record_optin_ip"`
|
||||
DomainBlocklist []string `json:"privacy.domain_blocklist"`
|
||||
DomainAllowlist []string `json:"privacy.domain_allowlist"`
|
||||
|
||||
SecurityCaptcha struct {
|
||||
Altcha struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Complexity int `json:"complexity"`
|
||||
} `json:"altcha"`
|
||||
HCaptcha struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Key string `json:"key"`
|
||||
Secret string `json:"secret"`
|
||||
} `json:"hcaptcha"`
|
||||
} `json:"security.captcha"`
|
||||
|
||||
OIDC struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ProviderURL string `json:"provider_url"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
AutoCreateUsers bool `json:"auto_create_users"`
|
||||
DefaultUserRoleID null.Int `json:"default_user_role_id"`
|
||||
DefaultListRoleID null.Int `json:"default_list_role_id"`
|
||||
} `json:"security.oidc"`
|
||||
|
||||
SecurityTrustedURLs []string `json:"security.trusted_urls"`
|
||||
|
||||
UploadProvider string `json:"upload.provider"`
|
||||
UploadExtensions []string `json:"upload.extensions"`
|
||||
UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"`
|
||||
UploadFilesystemUploadURI string `json:"upload.filesystem.upload_uri"`
|
||||
UploadS3URL string `json:"upload.s3.url"`
|
||||
UploadS3PublicURL string `json:"upload.s3.public_url"`
|
||||
UploadS3AwsAccessKeyID string `json:"upload.s3.aws_access_key_id"`
|
||||
UploadS3AwsDefaultRegion string `json:"upload.s3.aws_default_region"`
|
||||
UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"`
|
||||
UploadS3Bucket string `json:"upload.s3.bucket"`
|
||||
UploadS3BucketDomain string `json:"upload.s3.bucket_domain"`
|
||||
UploadS3BucketPath string `json:"upload.s3.bucket_path"`
|
||||
UploadS3BucketType string `json:"upload.s3.bucket_type"`
|
||||
UploadS3Expiry string `json:"upload.s3.expiry"`
|
||||
|
||||
SMTP []struct {
|
||||
Name string `json:"name"`
|
||||
UUID string `json:"uuid"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
HelloHostname string `json:"hello_hostname"`
|
||||
Port int `json:"port"`
|
||||
AuthProtocol string `json:"auth_protocol"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
EmailHeaders []map[string]string `json:"email_headers"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
MaxMsgRetries int `json:"max_msg_retries"`
|
||||
MsgRetryDelay string `json:"msg_retry_delay"`
|
||||
IdleTimeout string `json:"idle_timeout"`
|
||||
WaitTimeout string `json:"wait_timeout"`
|
||||
TLSType string `json:"tls_type"`
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
FromAddresses []string `json:"from_addresses"`
|
||||
} `json:"smtp"`
|
||||
|
||||
Messengers []struct {
|
||||
UUID string `json:"uuid"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
RootURL string `json:"root_url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
Timeout string `json:"timeout"`
|
||||
MaxMsgRetries int `json:"max_msg_retries"`
|
||||
} `json:"messengers"`
|
||||
|
||||
BounceEnabled bool `json:"bounce.enabled"`
|
||||
BounceEnableWebhooks bool `json:"bounce.webhooks_enabled"`
|
||||
BounceActions map[string]struct {
|
||||
Count int `json:"count"`
|
||||
Action string `json:"action"`
|
||||
} `json:"bounce.actions"`
|
||||
SESEnabled bool `json:"bounce.ses_enabled"`
|
||||
SendgridEnabled bool `json:"bounce.sendgrid_enabled"`
|
||||
SendgridKey string `json:"bounce.sendgrid_key"`
|
||||
BounceAzure struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SharedSecret string `json:"shared_secret"`
|
||||
SharedSecretHeader string `json:"shared_secret_header"`
|
||||
} `json:"bounce.azure"`
|
||||
BouncePostmark struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
} `json:"bounce.postmark"`
|
||||
BounceForwardEmail struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Key string `json:"key"`
|
||||
} `json:"bounce.forwardemail"`
|
||||
BounceLettermint struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Key string `json:"key"`
|
||||
} `json:"bounce.lettermint"`
|
||||
BounceBoxes []struct {
|
||||
UUID string `json:"uuid"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
AuthProtocol string `json:"auth_protocol"`
|
||||
ReturnPath string `json:"return_path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
ScanInterval string `json:"scan_interval"`
|
||||
} `json:"bounce.mailboxes"`
|
||||
|
||||
MaintenanceDB struct {
|
||||
Vacuum bool `json:"vacuum"`
|
||||
VacuumInterval string `json:"vacuum_cron_interval"`
|
||||
} `json:"maintenance.db"`
|
||||
|
||||
AdminCustomCSS string `json:"appearance.admin.custom_css"`
|
||||
AdminCustomJS string `json:"appearance.admin.custom_js"`
|
||||
PublicCustomCSS string `json:"appearance.public.custom_css"`
|
||||
PublicCustomJS string `json:"appearance.public.custom_js"`
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/lib/pq"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
const (
|
||||
SubscriberStatusEnabled = "enabled"
|
||||
SubscriberStatusDisabled = "disabled"
|
||||
SubscriberStatusBlockListed = "blocklisted"
|
||||
|
||||
SubscriptionStatusUnconfirmed = "unconfirmed"
|
||||
SubscriptionStatusConfirmed = "confirmed"
|
||||
SubscriptionStatusUnsubscribed = "unsubscribed"
|
||||
)
|
||||
|
||||
// Subscribers represents a slice of Subscriber.
|
||||
type Subscribers []Subscriber
|
||||
|
||||
// Subscriber represents an e-mail subscriber.
|
||||
type Subscriber struct {
|
||||
Base
|
||||
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Email string `db:"email" json:"email" form:"email"`
|
||||
Name string `db:"name" json:"name" form:"name"`
|
||||
Attribs JSON `db:"attribs" json:"attribs"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Lists types.JSONText `db:"lists" json:"lists"`
|
||||
}
|
||||
|
||||
type subLists struct {
|
||||
SubscriberID int `db:"subscriber_id"`
|
||||
Lists types.JSONText `db:"lists"`
|
||||
}
|
||||
|
||||
// GetIDs returns the list of subscriber IDs.
|
||||
func (subs Subscribers) GetIDs() []int {
|
||||
IDs := make([]int, len(subs))
|
||||
for i, c := range subs {
|
||||
IDs[i] = c.ID
|
||||
}
|
||||
|
||||
return IDs
|
||||
}
|
||||
|
||||
// LoadLists lazy loads the lists for all the subscribers
|
||||
// in the Subscribers slice and attaches them to their []Lists property.
|
||||
func (subs Subscribers) LoadLists(stmt *sqlx.Stmt) error {
|
||||
var sl []subLists
|
||||
err := stmt.Select(&sl, pq.Array(subs.GetIDs()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(subs) != len(sl) {
|
||||
return errors.New("campaign stats count does not match")
|
||||
}
|
||||
|
||||
for i, s := range sl {
|
||||
if s.SubscriberID == subs[i].ID {
|
||||
subs[i].Lists = s.Lists
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FirstName splits the name by spaces and returns the first chunk
|
||||
// of the name that's greater than 2 characters in length, assuming
|
||||
// that it is the subscriber's first name.
|
||||
func (s Subscriber) FirstName() string {
|
||||
for _, s := range strings.Split(s.Name, " ") {
|
||||
if len(s) > 2 {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
return s.Name
|
||||
}
|
||||
|
||||
// LastName splits the name by spaces and returns the last chunk
|
||||
// of the name that's greater than 2 characters in length, assuming
|
||||
// that it is the subscriber's last name.
|
||||
func (s Subscriber) LastName() string {
|
||||
chunks := strings.Split(s.Name, " ")
|
||||
for i := len(chunks) - 1; i >= 0; i-- {
|
||||
chunk := chunks[i]
|
||||
if len(chunk) > 2 {
|
||||
return chunk
|
||||
}
|
||||
}
|
||||
|
||||
return s.Name
|
||||
}
|
||||
|
||||
// Subscription represents a list attached to a subscriber.
|
||||
type Subscription struct {
|
||||
List
|
||||
SubscriptionStatus null.String `db:"subscription_status" json:"subscription_status"`
|
||||
SubscriptionCreatedAt null.String `db:"subscription_created_at" json:"subscription_created_at"`
|
||||
Meta json.RawMessage `db:"meta" json:"meta"`
|
||||
}
|
||||
|
||||
// SubscriberExport represents a subscriber record that is exported to raw data.
|
||||
type SubscriberExport struct {
|
||||
Base
|
||||
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Attribs string `db:"attribs" json:"attribs"`
|
||||
Status string `db:"status" json:"status"`
|
||||
}
|
||||
|
||||
// SubscriberExportProfile represents a subscriber's collated data in JSON for export.
|
||||
type SubscriberExportProfile struct {
|
||||
Email string `db:"email" json:"-"`
|
||||
Profile json.RawMessage `db:"profile" json:"profile,omitempty"`
|
||||
Subscriptions json.RawMessage `db:"subscriptions" json:"subscriptions,omitempty"`
|
||||
CampaignViews json.RawMessage `db:"campaign_views" json:"campaign_views,omitempty"`
|
||||
LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"`
|
||||
}
|
||||
|
||||
// SubscriberActivity represents a subscriber's campaign views and link clicks for the Activity tab.
|
||||
type SubscriberActivity struct {
|
||||
CampaignViews json.RawMessage `db:"campaign_views" json:"campaign_views"`
|
||||
LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks"`
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
txttpl "text/template"
|
||||
"time"
|
||||
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
const (
|
||||
BaseTpl = "base"
|
||||
ContentTpl = "content"
|
||||
TemplateTypeCampaign = "campaign"
|
||||
TemplateTypeCampaignVisual = "campaign_visual"
|
||||
TemplateTypeTx = "tx"
|
||||
)
|
||||
|
||||
// Template represents a reusable e-mail template.
|
||||
type Template struct {
|
||||
Base
|
||||
|
||||
Name string `db:"name" json:"name"`
|
||||
// Subject is only for type=tx.
|
||||
Subject string `db:"subject" json:"subject"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Body string `db:"body" json:"body,omitempty"`
|
||||
BodySource null.String `db:"body_source" json:"body_source,omitempty"`
|
||||
IsDefault bool `db:"is_default" json:"is_default"`
|
||||
|
||||
// Only relevant to tx (transactional) templates.
|
||||
SubjectTpl *txttpl.Template `json:"-"`
|
||||
Tpl *template.Template `json:"-"`
|
||||
Attachments []Attachment `json:"-"`
|
||||
}
|
||||
|
||||
// Compile compiles a template body and subject (only for tx templates) and
|
||||
// caches the templat references to be executed later.
|
||||
func (t *Template) Compile(f template.FuncMap) error {
|
||||
tpl, err := template.New(BaseTpl).Funcs(f).Parse(t.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling transactional template: %v", err)
|
||||
}
|
||||
t.Tpl = tpl
|
||||
|
||||
// If the subject line has a template string, compile it.
|
||||
if hasTplExpr(t.Subject) {
|
||||
subj := t.Subject
|
||||
|
||||
subjTpl, err := txttpl.New(BaseTpl).Funcs(txttpl.FuncMap(f)).Parse(subj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling subject: %v", err)
|
||||
}
|
||||
t.SubjectTpl = subjTpl
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CampaignStats struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Status string `db:"status" json:"status"`
|
||||
ToSend int `db:"to_send" json:"to_send"`
|
||||
Sent int `db:"sent" json:"sent"`
|
||||
Started null.Time `db:"started_at" json:"started_at"`
|
||||
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
|
||||
Rate int `json:"rate"`
|
||||
NetRate int `json:"net_rate"`
|
||||
}
|
||||
|
||||
type CampaignAnalyticsCount struct {
|
||||
CampaignID int `db:"campaign_id" json:"campaign_id"`
|
||||
Count int `db:"count" json:"count"`
|
||||
Timestamp time.Time `db:"timestamp" json:"timestamp"`
|
||||
}
|
||||
|
||||
type CampaignAnalyticsLink struct {
|
||||
URL string `db:"url" json:"url"`
|
||||
Count int `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
type CampaignViewExport struct {
|
||||
CampaignID int `db:"campaign_id"`
|
||||
CampaignUUID string `db:"campaign_uuid"`
|
||||
CampaignName string `db:"campaign_name"`
|
||||
SubscriberID int `db:"subscriber_id"`
|
||||
SubscriberUUID string `db:"subscriber_uuid"`
|
||||
Email string `db:"email"`
|
||||
SubscriberName string `db:"subscriber_name"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
type CampaignClickExport struct {
|
||||
CampaignID int `db:"campaign_id"`
|
||||
CampaignUUID string `db:"campaign_uuid"`
|
||||
CampaignName string `db:"campaign_name"`
|
||||
SubscriberID int `db:"subscriber_id"`
|
||||
SubscriberUUID string `db:"subscriber_uuid"`
|
||||
Email string `db:"email"`
|
||||
SubscriberName string `db:"subscriber_name"`
|
||||
URL string `db:"url"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
Reference in New Issue
Block a user