2018-10-25 21:51:47 +08:00
|
|
|
package models
|
|
|
|
|
|
|
|
import (
|
2021-04-11 18:43:43 +08:00
|
|
|
"bytes"
|
2018-10-25 21:51:47 +08:00
|
|
|
"database/sql/driver"
|
|
|
|
"encoding/json"
|
2019-04-01 19:37:24 +08:00
|
|
|
"errors"
|
2018-10-25 21:51:47 +08:00
|
|
|
"fmt"
|
|
|
|
"html/template"
|
2023-03-19 18:20:44 +08:00
|
|
|
"net/textproto"
|
2018-10-31 20:54:21 +08:00
|
|
|
"regexp"
|
2018-11-02 18:38:54 +08:00
|
|
|
"strings"
|
2022-05-03 13:11:46 +08:00
|
|
|
txttpl "text/template"
|
2021-05-25 01:11:48 +08:00
|
|
|
"time"
|
2018-10-25 21:51:47 +08:00
|
|
|
|
|
|
|
"github.com/jmoiron/sqlx"
|
2019-01-04 15:06:55 +08:00
|
|
|
"github.com/jmoiron/sqlx/types"
|
2018-10-25 21:51:47 +08:00
|
|
|
"github.com/lib/pq"
|
2021-04-11 18:43:43 +08:00
|
|
|
"github.com/yuin/goldmark"
|
2021-05-08 19:25:48 +08:00
|
|
|
"github.com/yuin/goldmark/extension"
|
2022-01-31 01:11:45 +08:00
|
|
|
"github.com/yuin/goldmark/parser"
|
2021-05-08 19:25:48 +08:00
|
|
|
"github.com/yuin/goldmark/renderer/html"
|
2018-10-25 21:51:47 +08:00
|
|
|
null "gopkg.in/volatiletech/null.v6"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Enum values for various statuses.
|
|
|
|
const (
|
|
|
|
// Subscriber.
|
|
|
|
SubscriberStatusEnabled = "enabled"
|
|
|
|
SubscriberStatusDisabled = "disabled"
|
2020-08-01 19:15:29 +08:00
|
|
|
SubscriberStatusBlockListed = "blocklisted"
|
2018-10-25 21:51:47 +08:00
|
|
|
|
2019-12-01 20:18:36 +08:00
|
|
|
// Subscription.
|
|
|
|
SubscriptionStatusUnconfirmed = "unconfirmed"
|
|
|
|
SubscriptionStatusConfirmed = "confirmed"
|
|
|
|
SubscriptionStatusUnsubscribed = "unsubscribed"
|
|
|
|
|
2018-10-25 21:51:47 +08:00
|
|
|
// Campaign.
|
2021-01-30 17:29:21 +08:00
|
|
|
CampaignStatusDraft = "draft"
|
|
|
|
CampaignStatusScheduled = "scheduled"
|
|
|
|
CampaignStatusRunning = "running"
|
|
|
|
CampaignStatusPaused = "paused"
|
|
|
|
CampaignStatusFinished = "finished"
|
|
|
|
CampaignStatusCancelled = "cancelled"
|
|
|
|
CampaignTypeRegular = "regular"
|
|
|
|
CampaignTypeOptin = "optin"
|
|
|
|
CampaignContentTypeRichtext = "richtext"
|
|
|
|
CampaignContentTypeHTML = "html"
|
2021-04-11 18:43:43 +08:00
|
|
|
CampaignContentTypeMarkdown = "markdown"
|
2021-01-30 17:29:21 +08:00
|
|
|
CampaignContentTypePlain = "plain"
|
2018-10-25 21:51:47 +08:00
|
|
|
|
|
|
|
// List.
|
|
|
|
ListTypePrivate = "private"
|
|
|
|
ListTypePublic = "public"
|
2019-12-01 20:18:36 +08:00
|
|
|
ListOptinSingle = "single"
|
|
|
|
ListOptinDouble = "double"
|
2018-10-25 21:51:47 +08:00
|
|
|
|
|
|
|
// User.
|
|
|
|
UserTypeUser = "user"
|
2024-05-07 13:38:31 +08:00
|
|
|
UserTypeAPI = "api"
|
2018-10-25 21:51:47 +08:00
|
|
|
UserStatusEnabled = "enabled"
|
|
|
|
UserStatusDisabled = "disabled"
|
2018-10-31 20:54:21 +08:00
|
|
|
|
|
|
|
// BaseTpl is the name of the base template.
|
|
|
|
BaseTpl = "base"
|
|
|
|
|
|
|
|
// ContentTpl is the name of the compiled message.
|
|
|
|
ContentTpl = "content"
|
2021-05-25 01:11:48 +08:00
|
|
|
|
|
|
|
// Headers attached to e-mails for bounce tracking.
|
|
|
|
EmailHeaderSubscriberUUID = "X-Listmonk-Subscriber"
|
|
|
|
EmailHeaderCampaignUUID = "X-Listmonk-Campaign"
|
|
|
|
|
2022-04-03 14:07:11 +08:00
|
|
|
// Standard e-mail headers.
|
|
|
|
EmailHeaderDate = "Date"
|
|
|
|
EmailHeaderFrom = "From"
|
|
|
|
EmailHeaderSubject = "Subject"
|
|
|
|
EmailHeaderMessageId = "Message-Id"
|
|
|
|
EmailHeaderDeliveredTo = "Delivered-To"
|
|
|
|
EmailHeaderReceived = "Received"
|
|
|
|
|
2023-04-11 14:03:13 +08:00
|
|
|
BounceTypeHard = "hard"
|
|
|
|
BounceTypeSoft = "soft"
|
|
|
|
BounceTypeComplaint = "complaint"
|
2022-07-02 18:00:17 +08:00
|
|
|
|
|
|
|
// Templates.
|
|
|
|
TemplateTypeCampaign = "campaign"
|
|
|
|
TemplateTypeTx = "tx"
|
2018-10-31 20:54:21 +08:00
|
|
|
)
|
|
|
|
|
2022-01-05 00:46:21 +08:00
|
|
|
// Headers represents an array of string maps used to represent SMTP, HTTP headers etc.
|
|
|
|
// similar to url.Values{}
|
|
|
|
type Headers []map[string]string
|
|
|
|
|
2019-12-07 01:10:30 +08:00
|
|
|
// 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{
|
2022-01-31 01:11:45 +08:00
|
|
|
// Regular expression for matching {{ TrackLink "http://link.com" }} in the template
|
|
|
|
// and substituting it with {{ Track "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 $3 . }}`,
|
|
|
|
},
|
|
|
|
|
2021-09-26 18:33:05 +08:00
|
|
|
// 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" }}>.
|
|
|
|
{
|
|
|
|
regExp: regexp.MustCompile(`(https?://.+?)@TrackLink`),
|
|
|
|
replace: `{{ TrackLink "$1" . }}`,
|
|
|
|
},
|
|
|
|
|
|
|
|
{
|
2022-10-19 00:14:57 +08:00
|
|
|
regExp: regexp.MustCompile(`{{(\s+)?(TrackView|UnsubscribeURL|ManageURL|OptinURL|MessageURL)(\s+)?}}`),
|
2019-12-07 01:10:30 +08:00
|
|
|
replace: `{{ $2 . }}`,
|
|
|
|
},
|
|
|
|
}
|
2018-10-25 21:51:47 +08:00
|
|
|
|
2018-11-28 15:59:57 +08:00
|
|
|
// AdminNotifCallback is a callback function that's called
|
|
|
|
// when a campaign's status changes.
|
2019-12-01 20:18:36 +08:00
|
|
|
type AdminNotifCallback func(subject string, data interface{}) error
|
2018-11-28 15:59:57 +08:00
|
|
|
|
2022-04-03 23:24:40 +08:00
|
|
|
// PageResults is a generic HTTP response container for paginated results of list of items.
|
|
|
|
type PageResults struct {
|
|
|
|
Results interface{} `json:"results"`
|
|
|
|
|
|
|
|
Query string `json:"query"`
|
|
|
|
Total int `json:"total"`
|
|
|
|
PerPage int `json:"per_page"`
|
|
|
|
Page int `json:"page"`
|
|
|
|
}
|
|
|
|
|
2018-10-25 21:51:47 +08:00
|
|
|
// 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"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// User represents an admin user.
|
|
|
|
type User struct {
|
|
|
|
Base
|
|
|
|
|
2024-05-23 14:24:10 +08:00
|
|
|
Username string `db:"username" json:"username"`
|
|
|
|
|
|
|
|
// For API users, this is the plaintext API token.
|
2024-06-24 02:38:37 +08:00
|
|
|
Password null.String `db:"password" json:"password,omitempty"`
|
|
|
|
PasswordLogin bool `db:"password_login" json:"password_login"`
|
|
|
|
Email null.String `db:"email" json:"email"`
|
|
|
|
Name string `db:"name" json:"name"`
|
|
|
|
Type string `db:"type" json:"type"`
|
|
|
|
Status string `db:"status" json:"status"`
|
2024-07-09 03:12:29 +08:00
|
|
|
Avatar null.String `db:"avatar" json:"avatar"`
|
2024-06-24 02:38:37 +08:00
|
|
|
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
|
|
|
|
|
|
|
|
// Filled post-retrieval.
|
|
|
|
Role struct {
|
|
|
|
ID int `db:"-" json:"id"`
|
|
|
|
Name string `db:"-" json:"name"`
|
|
|
|
Permissions []string `db:"-" json:"permissions"`
|
|
|
|
Lists []ListPermission `db:"-" json:"lists"`
|
|
|
|
} `db:"-" json:"role"`
|
|
|
|
|
|
|
|
RoleID int `db:"role_id" json:"role_id,omitempty"`
|
|
|
|
RoleName string `db:"role_name" json:"-"`
|
|
|
|
RolePerms pq.StringArray `db:"role_permissions" json:"-"`
|
|
|
|
ListsPermsRaw json.RawMessage `db:"list_permissions" json:"-"`
|
|
|
|
|
|
|
|
PermissionsMap map[string]struct{} `db:"-" json:"-"`
|
|
|
|
ListPermissionsMap map[int]map[string]struct{} `db:"-" json:"-"`
|
|
|
|
HasPassword bool `db:"-" json:"-"`
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
|
|
|
|
2024-06-24 01:20:24 +08:00
|
|
|
type ListPermission struct {
|
|
|
|
ID int `json:"id"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
Permissions pq.StringArray `json:"permissions"`
|
|
|
|
}
|
|
|
|
|
2024-06-15 17:44:55 +08:00
|
|
|
type Role struct {
|
|
|
|
Base
|
|
|
|
|
2024-06-24 01:20:24 +08:00
|
|
|
Name null.String `db:"name" json:"name"`
|
2024-06-15 17:44:55 +08:00
|
|
|
Permissions pq.StringArray `db:"permissions" json:"permissions"`
|
2024-06-24 01:20:24 +08:00
|
|
|
|
|
|
|
ListID null.Int `db:"list_id" json:"-"`
|
|
|
|
ParentID null.Int `db:"parent_id" json:"-"`
|
|
|
|
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
|
|
|
|
Lists []ListPermission `db:"-" json:"lists"`
|
2024-06-15 17:44:55 +08:00
|
|
|
}
|
|
|
|
|
2018-10-25 21:51:47 +08:00
|
|
|
// Subscriber represents an e-mail subscriber.
|
|
|
|
type Subscriber struct {
|
|
|
|
Base
|
|
|
|
|
2022-10-03 01:23:38 +08:00
|
|
|
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"`
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
2019-04-01 19:37:24 +08:00
|
|
|
type subLists struct {
|
|
|
|
SubscriberID int `db:"subscriber_id"`
|
|
|
|
Lists types.JSONText `db:"lists"`
|
|
|
|
}
|
2018-10-25 21:51:47 +08:00
|
|
|
|
2022-10-19 00:14:57 +08:00
|
|
|
// Subscription represents a list attached to a subscriber.
|
|
|
|
type Subscription struct {
|
|
|
|
List
|
2023-07-22 15:58:45 +08:00
|
|
|
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"`
|
2022-10-19 00:14:57 +08:00
|
|
|
}
|
|
|
|
|
2022-04-03 23:24:40 +08:00
|
|
|
// 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"`
|
|
|
|
}
|
|
|
|
|
2024-03-11 16:03:50 +08:00
|
|
|
// JSON is the wrapper for reading and writing arbitrary JSONB fields from the DB.
|
2022-10-03 01:23:38 +08:00
|
|
|
type JSON map[string]interface{}
|
2018-10-25 21:51:47 +08:00
|
|
|
|
2022-02-03 02:33:31 +08:00
|
|
|
// StringIntMap is used to define DB Scan()s.
|
|
|
|
type StringIntMap map[string]int
|
|
|
|
|
2018-10-25 21:51:47 +08:00
|
|
|
// Subscribers represents a slice of Subscriber.
|
|
|
|
type Subscribers []Subscriber
|
|
|
|
|
2021-01-23 20:53:29 +08:00
|
|
|
// 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"`
|
|
|
|
}
|
|
|
|
|
2018-10-25 21:51:47 +08:00
|
|
|
// List represents a mailing list.
|
|
|
|
type List struct {
|
|
|
|
Base
|
|
|
|
|
2022-02-03 02:33:31 +08:00
|
|
|
UUID string `db:"uuid" json:"uuid"`
|
|
|
|
Name string `db:"name" json:"name"`
|
|
|
|
Type string `db:"type" json:"type"`
|
|
|
|
Optin string `db:"optin" json:"optin"`
|
|
|
|
Tags pq.StringArray `db:"tags" json:"tags"`
|
2022-11-01 23:29:21 +08:00
|
|
|
Description string `db:"description" json:"description"`
|
2022-02-03 02:33:31 +08:00
|
|
|
SubscriberCount int `db:"-" json:"subscriber_count"`
|
|
|
|
SubscriberCounts StringIntMap `db:"subscriber_statuses" json:"subscriber_statuses"`
|
|
|
|
SubscriberID int `db:"subscriber_id" json:"-"`
|
2018-10-25 21:51:47 +08:00
|
|
|
|
|
|
|
// This is only relevant when querying the lists of a subscriber.
|
2022-11-10 00:10:11 +08:00
|
|
|
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"`
|
2019-05-14 19:11:05 +08:00
|
|
|
|
|
|
|
// Pseudofield for getting the total number of subscribers
|
|
|
|
// in searches and queries.
|
|
|
|
Total int `db:"total" json:"-"`
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Campaign represents an e-mail campaign.
|
|
|
|
type Campaign struct {
|
|
|
|
Base
|
|
|
|
CampaignMeta
|
|
|
|
|
2022-11-03 13:37:26 +08:00
|
|
|
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"`
|
2024-01-10 02:04:08 +08:00
|
|
|
ArchiveSlug null.String `db:"archive_slug" json:"archive_slug"`
|
2022-11-03 13:37:26 +08:00
|
|
|
ArchiveTemplateID int `db:"archive_template_id" json:"archive_template_id"`
|
|
|
|
ArchiveMeta json.RawMessage `db:"archive_meta" json:"archive_meta"`
|
2018-10-25 21:51:47 +08:00
|
|
|
|
|
|
|
// TemplateBody is joined in from templates by the next-campaigns query.
|
2022-11-03 13:37:26 +08:00
|
|
|
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:"-"`
|
2019-03-28 19:47:51 +08:00
|
|
|
|
2023-05-18 19:25:59 +08:00
|
|
|
// 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:"-"`
|
|
|
|
|
2019-03-28 19:47:51 +08:00
|
|
|
// Pseudofield for getting the total number of subscribers
|
|
|
|
// in searches and queries.
|
|
|
|
Total int `db:"total" json:"-"`
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// CampaignMeta contains fields tracking a campaign's progress.
|
|
|
|
type CampaignMeta struct {
|
2020-06-07 02:03:55 +08:00
|
|
|
CampaignID int `db:"campaign_id" json:"-"`
|
2019-04-01 19:37:24 +08:00
|
|
|
Views int `db:"views" json:"views"`
|
|
|
|
Clicks int `db:"clicks" json:"clicks"`
|
2021-05-25 01:11:48 +08:00
|
|
|
Bounces int `db:"bounces" json:"bounces"`
|
2019-04-01 19:37:24 +08:00
|
|
|
|
|
|
|
// 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"`
|
2023-05-18 19:25:59 +08:00
|
|
|
Media types.JSONText `db:"media" json:"media"`
|
2019-04-01 19:37:24 +08:00
|
|
|
|
2018-10-25 21:51:47 +08:00
|
|
|
StartedAt null.Time `db:"started_at" json:"started_at"`
|
|
|
|
ToSend int `db:"to_send" json:"to_send"`
|
|
|
|
Sent int `db:"sent" json:"sent"`
|
|
|
|
}
|
|
|
|
|
2022-04-03 23:24:40 +08:00
|
|
|
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"`
|
|
|
|
}
|
|
|
|
|
2019-04-01 19:37:24 +08:00
|
|
|
// Campaigns represents a slice of Campaigns.
|
2018-10-25 21:51:47 +08:00
|
|
|
type Campaigns []Campaign
|
|
|
|
|
|
|
|
// Template represents a reusable e-mail template.
|
|
|
|
type Template struct {
|
|
|
|
Base
|
|
|
|
|
2022-07-02 18:00:17 +08:00
|
|
|
Name string `db:"name" json:"name"`
|
|
|
|
// Subject is only for type=tx.
|
|
|
|
Subject string `db:"subject" json:"subject"`
|
|
|
|
Type string `db:"type" json:"type"`
|
2018-10-25 21:51:47 +08:00
|
|
|
Body string `db:"body" json:"body,omitempty"`
|
|
|
|
IsDefault bool `db:"is_default" json:"is_default"`
|
2022-07-02 18:00:17 +08:00
|
|
|
|
|
|
|
// Only relevant to tx (transactional) templates.
|
|
|
|
SubjectTpl *txttpl.Template `json:"-"`
|
|
|
|
Tpl *template.Template `json:"-"`
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
|
|
|
|
2021-05-25 01:11:48 +08:00
|
|
|
// 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"`
|
|
|
|
|
|
|
|
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:"-"`
|
|
|
|
}
|
|
|
|
|
2023-05-09 01:13:25 +08:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2022-07-02 18:00:17 +08:00
|
|
|
// TxMessage represents an e-mail campaign.
|
|
|
|
type TxMessage struct {
|
2022-12-25 20:01:22 +08:00
|
|
|
SubscriberEmails []string `json:"subscriber_emails"`
|
|
|
|
SubscriberIDs []int `json:"subscriber_ids"`
|
|
|
|
|
|
|
|
// Deprecated.
|
2022-07-02 18:00:17 +08:00
|
|
|
SubscriberEmail string `json:"subscriber_email"`
|
|
|
|
SubscriberID int `json:"subscriber_id"`
|
|
|
|
|
|
|
|
TemplateID int `json:"template_id"`
|
|
|
|
Data map[string]interface{} `json:"data"`
|
|
|
|
FromEmail string `json:"from_email"`
|
|
|
|
Headers Headers `json:"headers"`
|
|
|
|
ContentType string `json:"content_type"`
|
|
|
|
Messenger string `json:"messenger"`
|
|
|
|
|
2023-03-19 18:20:44 +08:00
|
|
|
// File attachments added from multi-part form data.
|
2023-05-09 01:13:25 +08:00
|
|
|
Attachments []Attachment `json:"-"`
|
2023-03-19 18:20:44 +08:00
|
|
|
|
2022-07-02 18:00:17 +08:00
|
|
|
Subject string `json:"-"`
|
|
|
|
Body []byte `json:"-"`
|
|
|
|
Tpl *template.Template `json:"-"`
|
|
|
|
SubjectTpl *txttpl.Template `json:"-"`
|
|
|
|
}
|
|
|
|
|
2021-05-08 19:25:48 +08:00
|
|
|
// markdown is a global instance of Markdown parser and renderer.
|
|
|
|
var markdown = goldmark.New(
|
2022-01-04 16:12:15 +08:00
|
|
|
goldmark.WithParserOptions(
|
|
|
|
parser.WithAutoHeadingID(),
|
|
|
|
),
|
2021-05-08 19:25:48 +08:00
|
|
|
goldmark.WithRendererOptions(
|
|
|
|
html.WithXHTML(),
|
|
|
|
html.WithUnsafe(),
|
|
|
|
),
|
|
|
|
goldmark.WithExtensions(
|
|
|
|
extension.Table,
|
|
|
|
extension.Strikethrough,
|
|
|
|
extension.TaskList,
|
2024-02-04 14:02:20 +08:00
|
|
|
extension.NewTypographer(
|
|
|
|
extension.WithTypographicSubstitutions(extension.TypographicSubstitutions{
|
|
|
|
extension.LeftDoubleQuote: []byte(`"`),
|
|
|
|
extension.RightDoubleQuote: []byte(`"`),
|
|
|
|
}),
|
|
|
|
),
|
2021-05-08 19:25:48 +08:00
|
|
|
),
|
|
|
|
)
|
|
|
|
|
2019-04-01 19:37:24 +08:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2018-10-25 21:51:47 +08:00
|
|
|
// 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 {
|
2019-04-01 19:37:24 +08:00
|
|
|
var sl []subLists
|
|
|
|
err := stmt.Select(&sl, pq.Array(subs.GetIDs()))
|
2018-10-25 21:51:47 +08:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2019-04-01 19:37:24 +08:00
|
|
|
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
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Value returns the JSON marshalled SubscriberAttribs.
|
2022-10-03 01:23:38 +08:00
|
|
|
func (s JSON) Value() (driver.Value, error) {
|
2018-10-25 21:51:47 +08:00
|
|
|
return json.Marshal(s)
|
|
|
|
}
|
|
|
|
|
2022-02-03 02:33:31 +08:00
|
|
|
// Scan unmarshals JSONB from the DB.
|
2022-10-03 01:23:38 +08:00
|
|
|
func (s JSON) Scan(src interface{}) error {
|
2022-02-05 21:18:41 +08:00
|
|
|
if src == nil {
|
2022-10-03 01:23:38 +08:00
|
|
|
s = make(JSON)
|
2022-02-05 21:18:41 +08:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2018-10-25 21:51:47 +08:00
|
|
|
if data, ok := src.([]byte); ok {
|
|
|
|
return json.Unmarshal(data, &s)
|
|
|
|
}
|
2022-02-03 02:33:31 +08:00
|
|
|
return fmt.Errorf("could not not decode type %T -> %T", src, s)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Scan unmarshals JSONB from the DB.
|
|
|
|
func (s StringIntMap) Scan(src interface{}) error {
|
2022-02-05 21:18:41 +08:00
|
|
|
if src == nil {
|
|
|
|
s = make(StringIntMap)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-02-03 02:33:31 +08:00
|
|
|
if data, ok := src.([]byte); ok {
|
|
|
|
return json.Unmarshal(data, &s)
|
|
|
|
}
|
|
|
|
return fmt.Errorf("could not not decode type %T -> %T", src, s)
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
2018-10-31 20:54:21 +08:00
|
|
|
|
2019-04-01 19:37:24 +08:00
|
|
|
// 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
|
2021-05-25 01:11:48 +08:00
|
|
|
camps[i].Bounces = c.Bounces
|
2023-05-18 19:25:59 +08:00
|
|
|
camps[i].Media = c.Media
|
2019-04-01 19:37:24 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2018-10-31 20:54:21 +08:00
|
|
|
// CompileTemplate compiles a campaign body template into its base
|
2018-11-26 20:06:05 +08:00
|
|
|
// template and sets the resultant template to Campaign.Tpl.
|
2018-10-31 20:54:21 +08:00
|
|
|
func (c *Campaign) CompileTemplate(f template.FuncMap) error {
|
2022-11-03 13:37:26 +08:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2018-10-31 20:54:21 +08:00
|
|
|
// Compile the base template.
|
2019-12-07 01:10:30 +08:00
|
|
|
body := c.TemplateBody
|
|
|
|
for _, r := range regTplFuncs {
|
|
|
|
body = r.regExp.ReplaceAllString(body, r.replace)
|
|
|
|
}
|
|
|
|
baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(body)
|
2018-10-31 20:54:21 +08:00
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error compiling base template: %v", err)
|
|
|
|
}
|
|
|
|
|
2021-04-11 18:43:43 +08:00
|
|
|
// If the format is markdown, convert Markdown to HTML.
|
|
|
|
if c.ContentType == CampaignContentTypeMarkdown {
|
|
|
|
var b bytes.Buffer
|
2021-05-08 19:25:48 +08:00
|
|
|
if err := markdown.Convert([]byte(c.Body), &b); err != nil {
|
2021-04-11 18:43:43 +08:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
body = b.String()
|
|
|
|
} else {
|
|
|
|
body = c.Body
|
|
|
|
}
|
|
|
|
|
2018-10-31 20:54:21 +08:00
|
|
|
// Compile the campaign message.
|
2019-12-07 01:10:30 +08:00
|
|
|
for _, r := range regTplFuncs {
|
|
|
|
body = r.regExp.ReplaceAllString(body, r.replace)
|
|
|
|
}
|
2022-01-31 01:11:45 +08:00
|
|
|
|
2019-12-07 01:10:30 +08:00
|
|
|
msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(body)
|
2018-10-31 20:54:21 +08:00
|
|
|
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)
|
|
|
|
}
|
2021-01-30 17:29:21 +08:00
|
|
|
c.Tpl = out
|
2018-10-31 20:54:21 +08:00
|
|
|
|
2021-01-30 17:29:21 +08:00
|
|
|
if strings.Contains(c.AltBody.String, "{{") {
|
|
|
|
b := c.AltBody.String
|
|
|
|
for _, r := range regTplFuncs {
|
|
|
|
b = r.regExp.ReplaceAllString(b, r.replace)
|
|
|
|
}
|
|
|
|
bTpl, err := template.New(ContentTpl).Funcs(f).Parse(b)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error compiling alt plaintext message: %v", err)
|
|
|
|
}
|
|
|
|
c.AltBodyTpl = bTpl
|
|
|
|
}
|
|
|
|
|
2018-10-31 20:54:21 +08:00
|
|
|
return nil
|
|
|
|
}
|
2018-11-02 18:38:54 +08:00
|
|
|
|
2021-05-09 18:06:31 +08:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2022-07-02 18:00:17 +08:00
|
|
|
// 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 strings.Contains(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
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *TxMessage) Render(sub Subscriber, tpl *Template) 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()
|
|
|
|
|
|
|
|
// If the subject is also a template, render that.
|
|
|
|
if tpl.SubjectTpl != nil {
|
|
|
|
if err := tpl.SubjectTpl.ExecuteTemplate(&b, BaseTpl, data); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
m.Subject = b.String()
|
|
|
|
b.Reset()
|
2022-08-20 22:27:16 +08:00
|
|
|
} else {
|
|
|
|
m.Subject = tpl.Subject
|
2022-07-02 18:00:17 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2018-11-02 18:38:54 +08:00
|
|
|
// 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.
|
2019-12-01 20:18:36 +08:00
|
|
|
func (s Subscriber) FirstName() string {
|
2018-11-02 18:38:54 +08:00
|
|
|
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.
|
2019-12-01 20:18:36 +08:00
|
|
|
func (s Subscriber) LastName() string {
|
2018-11-02 18:38:54 +08:00
|
|
|
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
|
|
|
|
}
|
2022-01-05 00:46:21 +08:00
|
|
|
|
|
|
|
// Scan implements the sql.Scanner interface.
|
|
|
|
func (h *Headers) Scan(src interface{}) 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
|
|
|
|
}
|