EagleCast
publish-github-pages / deploy (push) Waiting to run

This commit is contained in:
h202-wq
2026-07-09 10:03:32 -04:00
commit 66d9a033c9
446 changed files with 162542 additions and 0 deletions
+34
View File
@@ -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:"-"`
}
+272
View File
@@ -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
}
+184
View File
@@ -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
}
+40
View File
@@ -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:"-"`
}
+137
View File
@@ -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
}
+195
View File
@@ -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
}
+167
View File
@@ -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"`
}
+136
View File
@@ -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"`
}
+104
View File
@@ -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"`
}