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
+472
View File
@@ -0,0 +1,472 @@
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/labstack/echo/v4"
"github.com/zerodha/simplesessions/stores/postgres/v3"
"github.com/zerodha/simplesessions/v3"
"golang.org/x/oauth2"
)
type OIDCclaim struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Sub string `json:"sub"`
Picture string `json:"picture"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
type OIDCConfig struct {
Enabled bool `json:"enabled"`
ProviderURL string `json:"provider_url"`
RedirectURL string `json:"redirect_url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AutoCreateUsers bool `json:"auto_create_users"`
DefaultUserRoleID int `json:"default_user_role_id"`
DefaultListRoleID int `json:"default_list_role_id"`
}
type BasicAuthConfig struct {
Enabled bool `json:"enabled"`
Username string `json:"username"`
Password string `json:"password"`
}
type Config struct {
OIDC OIDCConfig
BasicAuth BasicAuthConfig
}
// Callbacks takes two callback functions required by simplesessions.
type Callbacks struct {
SetCookie func(cookie *http.Cookie, w any) error
GetCookie func(name string, r any) (*http.Cookie, error)
GetUser func(id int) (User, error)
}
type Auth struct {
apiUsers map[string]User
sync.RWMutex
cfg Config
oauthCfg oauth2.Config
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
sess *simplesessions.Manager
sessStore *postgres.Store
cb *Callbacks
log *log.Logger
}
var sessPruneInterval = time.Hour * 12
// New returns an initialize Auth instance.
func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, error) {
a := &Auth{
cfg: cfg,
cb: cb,
log: lo,
apiUsers: map[string]User{},
}
// Initialize session manager.
a.sess = simplesessions.New(simplesessions.Options{
EnableAutoCreate: false,
SessionIDLength: 64,
Cookie: simplesessions.CookieOptions{
IsHTTPOnly: true,
MaxAge: time.Hour * 24 * 7,
},
})
st, err := postgres.New(postgres.Opt{}, db)
if err != nil {
return nil, err
}
a.sessStore = st
a.sess.UseStore(st)
a.sess.SetCookieHooks(cb.GetCookie, cb.SetCookie)
// Prune dead sessions from the DB periodically.
go func() {
if err := st.Prune(); err != nil {
lo.Printf("error pruning login sessions: %v", err)
}
time.Sleep(sessPruneInterval)
}()
return a, nil
}
// CacheAPIUsers caches API users for authenticating requests. It wipes
// the existing cache every time and is meant for syncing all API users
// in the database in one shot.
func (o *Auth) CacheAPIUsers(users []User) {
o.Lock()
defer o.Unlock()
o.apiUsers = map[string]User{}
for _, u := range users {
o.apiUsers[u.Username] = u
}
}
// CacheAPIUser caches an API user for authenticating requests.
func (o *Auth) CacheAPIUser(u User) {
o.Lock()
o.apiUsers[u.Username] = u
o.Unlock()
}
// GetAPIToken validates an API user+token.
func (o *Auth) GetAPIToken(user string, token string) (User, bool) {
o.RLock()
t, ok := o.apiUsers[user]
o.RUnlock()
if !ok || subtle.ConstantTimeCompare([]byte(t.Password.String), []byte(HashAPIToken(token))) != 1 {
return User{}, false
}
return t, true
}
// initOIDC initializes the OIDC provider, verifier, and OAuth config.
func (o *Auth) initOIDC() error {
if !o.cfg.OIDC.Enabled {
return fmt.Errorf("OIDC is not enabled")
}
provider, err := oidc.NewProvider(context.Background(), o.cfg.OIDC.ProviderURL)
if err != nil {
return fmt.Errorf("error initializing OIDC OAuth provider: %v", err)
}
o.verifier = provider.Verifier(&oidc.Config{
ClientID: o.cfg.OIDC.ClientID,
})
o.oauthCfg = oauth2.Config{
ClientID: o.cfg.OIDC.ClientID,
ClientSecret: o.cfg.OIDC.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: o.cfg.OIDC.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
o.provider = provider
return nil
}
// getProvider returns the OIDC provider, initializing it if necessary.
func (o *Auth) getProvider() (*oidc.Provider, error) {
o.Lock()
defer o.Unlock()
if o.provider == nil {
if err := o.initOIDC(); err != nil {
return nil, err
}
}
return o.provider, nil
}
// getVerifier returns the OIDC verifier, initializing it if necessary.
func (o *Auth) getVerifier() (*oidc.IDTokenVerifier, error) {
o.Lock()
defer o.Unlock()
if o.verifier == nil {
if err := o.initOIDC(); err != nil {
return nil, err
}
}
return o.verifier, nil
}
// getOAuthConfig returns the OAuth config, initializing it if necessary.
func (o *Auth) getOAuthConfig() (*oauth2.Config, error) {
o.Lock()
defer o.Unlock()
if o.oauthCfg.ClientID == "" {
if err := o.initOIDC(); err != nil {
return nil, err
}
}
return &o.oauthCfg, nil
}
// GetOIDCAuthURL returns the OIDC provider's auth URL to redirect to.
func (o *Auth) GetOIDCAuthURL(state, nonce string) string {
cfg, err := o.getOAuthConfig()
if err != nil {
o.log.Printf("error getting OAuth config: %v", err)
return ""
}
return cfg.AuthCodeURL(state, oidc.Nonce(nonce))
}
// ExchangeOIDCToken takes an OIDC authorization code (recieved via redirect from the OIDC provider),
// validates it, and returns an OIDC token for subsequent auth.
func (o *Auth) ExchangeOIDCToken(code, nonce string) (string, OIDCclaim, error) {
cfg, err := o.getOAuthConfig()
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error getting OAuth config: %v", err))
}
tk, err := cfg.Exchange(context.TODO(), code)
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error exchanging token: %v", err))
}
rawIDTk, ok := tk.Extra("id_token").(string)
if !ok {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "`id_token` missing.")
}
verifier, err := o.getVerifier()
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error getting verifier: %v", err))
}
idTk, err := verifier.Verify(context.TODO(), rawIDTk)
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error verifying ID token: %v", err))
}
if idTk.Nonce != nonce {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "nonce did not match")
}
var claims OIDCclaim
if err := idTk.Claims(&claims); err != nil {
return "", OIDCclaim{}, errors.New("error getting user from OIDC")
}
// If claims doesn't have the e-mail, attempt to fetch it from the userinfo endpoint.
if claims.Email == "" {
provider, err := o.getProvider()
if err != nil {
return "", OIDCclaim{}, fmt.Errorf("error getting provider: %v", err)
}
userInfo, err := provider.UserInfo(context.TODO(), oauth2.StaticTokenSource(tk))
if err != nil {
return "", OIDCclaim{}, errors.New("error fetching user info from OIDC")
}
// Parse the UserInfo claims into the claims struct
if err := userInfo.Claims(&claims); err != nil {
return "", OIDCclaim{}, errors.New("error parsing user info claims")
}
}
return rawIDTk, claims, nil
}
// Middleware is the HTTP middleware used for wrapping HTTP handlers registered on the echo router.
// It authorizes token (BasicAuth/token) based and cookie based sessions and on successful auth,
// sets the authenticated User{} on the echo context on the key UserKey. On failure, it sets an Error{}
// instead on the same key.
func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// It's an `Authorization` header request.
hdr := strings.TrimSpace(c.Request().Header.Get("Authorization"))
// If cookie is set, ignore BasicAuth. This is to preserve backwards compatibility
// in v3 -> v4 upgrade where the user browser sessions would still have old
// BasicAuth credentials, which no longer work in the new system which expects
// session cookies instead, which causes a redirect loop despite loggin in and session
// cookies being set.
//
// TODO: This should be removed in a future version.
if c := strings.TrimSpace(c.Request().Header.Get("Cookie")); strings.Contains(c, "session=") {
hdr = ""
}
if len(hdr) > 0 {
key, token, err := parseAuthHeader(hdr)
if err != nil {
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, err.Error()))
return next(c)
}
// Validate the token.
user, ok := o.GetAPIToken(key, token)
if !ok {
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid API credentials"))
return next(c)
}
// Set the user details on the handler context.
c.Set(UserHTTPCtxKey, user)
return next(c)
}
// Is it a cookie based session?
sess, user, err := o.validateSession(c)
if err != nil {
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
return next(c)
}
// Set the user details on the handler context.
c.Set(UserHTTPCtxKey, user)
c.Set(SessionKey, sess)
return next(c)
}
}
// Perm is an HTTP handler middleware that checks if the authenticated user has the required permissions.
func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
return func(c echo.Context) error {
u, ok := c.Get(UserHTTPCtxKey).(User)
if !ok {
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
return next(c)
}
// If the current user is a Super Admin user, do no checks.
if u.UserRole.ID == SuperAdminRoleID {
return next(c)
}
// Check if the current handler's permission is in the user's permission map.
var (
has = false
perm = ""
)
for _, perm = range perms {
if _, ok := u.PermissionsMap[perm]; ok {
has = true
break
}
}
if !has {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied: %s", perm))
}
return next(c)
}
}
// SaveSession creates and sets a session (post successful login/auth).
func (o *Auth) SaveSession(u User, oidcToken string, c echo.Context) error {
sess, err := o.sess.NewSession(c, c)
if err != nil {
o.log.Printf("error creating login session: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "error creating session")
}
if err := sess.SetMulti(map[string]any{"user_id": u.ID, "oidc_token": oidcToken}); err != nil {
o.log.Printf("error setting login session: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "error creating session")
}
return nil
}
// GetSessionID returns the current session ID from the echo context.
func GetSessionID(c echo.Context) string {
sess, ok := c.Get(SessionKey).(*simplesessions.Session)
if !ok || sess == nil {
return ""
}
return sess.ID()
}
// HashAPIToken returns the SHA-256 hex digest of an API token.
func HashAPIToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// validateSession checks if the cookie session is valid (in the DB) and returns the session and user details.
func (o *Auth) validateSession(c echo.Context) (*simplesessions.Session, User, error) {
// Cookie session.
sess, err := o.sess.Acquire(context.TODO(), c, c)
if err != nil {
return nil, User{}, echo.NewHTTPError(http.StatusForbidden, err.Error())
}
// Get the session variables.
vars, err := sess.GetMulti("user_id", "oidc_token")
if err != nil {
return nil, User{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
// Validate the user ID in the session.
userID, err := o.sessStore.Int(vars["user_id"], nil)
if err != nil || userID < 1 {
o.log.Printf("error fetching session user ID: %v", err)
return nil, User{}, echo.NewHTTPError(http.StatusInternalServerError, "invalid session.")
}
// Fetch user details from the database.
user, err := o.cb.GetUser(userID)
if err != nil {
o.log.Printf("error fetching session user: %v", err)
}
return sess, user, err
}
// GetUser retrieves and returns the User object from an authenticated
// HTTP handler request.
func GetUser(c echo.Context) User {
return c.Get(UserHTTPCtxKey).(User)
}
// parseAuthHeader parses the Authorization header and returns the api_key and access_token.
func parseAuthHeader(h string) (string, string, error) {
const authBasic = "Basic"
const authToken = "token"
var (
pair []string
delim = ":"
)
if strings.HasPrefix(h, authToken) {
// token api_key:access_token.
pair = strings.SplitN(strings.Trim(h[len(authToken):], " "), delim, 2)
} else if strings.HasPrefix(h, authBasic) {
// HTTP BasicAuth. This is supported for backwards compatibility.
payload, err := base64.StdEncoding.DecodeString(string(strings.Trim(h[len(authBasic):], " ")))
if err != nil {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "invalid Base64 value in Basic Authorization header")
}
pair = strings.SplitN(string(payload), delim, 2)
} else {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "unknown Authorization scheme")
}
if len(pair) < 2 {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "api_key:token missing")
}
if len(pair[0]) == 0 || len(pair[1]) == 0 {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "empty `api_key` or `token`")
}
return pair[0], pair[1], nil
}
+330
View File
@@ -0,0 +1,330 @@
package auth
import (
"encoding/json"
"net/http"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
null "gopkg.in/volatiletech/null.v6"
)
var ErrPermDenied = echo.NewHTTPError(http.StatusForbidden, "permission denied")
// PermType indicates a generic permission type which is either get (read) or manage (write).
type PermType uint8
const (
PermTypeGet PermType = 1 << iota
PermTypeManage
)
const (
// UserHTTPCtxKey is the key on which the User profile is set on echo handlers.
UserHTTPCtxKey = "auth_user"
SessionKey = "auth_session"
)
const (
// SuperAdminRoleID is the database ID of the primordial super admin role.
SuperAdminRoleID = 1
// User.
UserTypeUser = "user"
UserTypeAPI = "api"
UserStatusEnabled = "enabled"
UserStatusDisabled = "disabled"
// Role.
RoleTypeUser = "user"
RoleTypeList = "list"
)
// List of all granular permissions.
const (
PermListGetAll = "lists:get_all"
PermListManageAll = "lists:manage_all"
PermListManage = "list:manage"
PermListGet = "list:get"
PermSubscribersGet = "subscribers:get"
PermSubscribersGetAll = "subscribers:get_all"
PermSubscribersManage = "subscribers:manage"
PermSubscribersImport = "subscribers:import"
PermSubscribersSqlQuery = "subscribers:sql_query"
PermTxSend = "tx:send"
PermCampaignsGet = "campaigns:get"
PermCampaignsGetAll = "campaigns:get_all"
PermCampaignsGetAnalytics = "campaigns:get_analytics"
PermCampaignsManage = "campaigns:manage"
PermCampaignsManageAll = "campaigns:manage_all"
PermCampaignsSend = "campaigns:send"
PermBouncesGet = "bounces:get"
PermBouncesManage = "bounces:manage"
PermWebhooksPostBounce = "webhooks:post_bounce"
PermMediaGet = "media:get"
PermMediaManage = "media:manage"
PermTemplatesGet = "templates:get"
PermTemplatesManage = "templates:manage"
PermUsersGet = "users:get"
PermUsersManage = "users:manage"
PermRolesGet = "roles:get"
PermRolesManage = "roles:manage"
PermSettingsGet = "settings:get"
PermSettingsManage = "settings:manage"
PermSettingsMaintain = "settings:maintain"
)
// 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
Username string `db:"username" json:"username"`
// For API users, this stores the SHA-256 token hash.
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"`
Avatar null.String `db:"avatar" json:"avatar"`
TwofaType string `db:"twofa_type" json:"twofa_type"`
TwofaKey null.String `db:"twofa_key" json:"-"`
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
UserRoleID int `db:"user_role_id" json:"user_role_id,omitempty"`
UserRoleName string `db:"user_role_name" json:"-"`
ListRoleID *int `db:"list_role_id" json:"list_role_id,omitempty"`
ListRoleName null.String `db:"list_role_name" json:"-"`
UserRolePerms pq.StringArray `db:"user_role_permissions" json:"-"`
ListsPermsRaw *json.RawMessage `db:"list_role_perms" json:"-"`
// Non-DB fields filled post-retrieval.
UserRole struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Permissions []string `db:"-" json:"permissions"`
} `db:"-" json:"user_role"`
ListRole *ListRolePermissions `db:"-" json:"list_role"`
PermissionsMap map[string]struct{} `db:"-" json:"-"`
ListPermissionsMap map[int]map[string]struct{} `db:"-" json:"-"`
GetListIDs []int `db:"-" json:"-"`
ManageListIDs []int `db:"-" json:"-"`
HasPassword bool `db:"-" json:"-"`
}
type ListPermission struct {
ID int `json:"id"`
Name string `json:"name"`
Permissions pq.StringArray `json:"permissions"`
}
type ListRolePermissions struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Lists []ListPermission `db:"-" json:"lists"`
}
type Role struct {
Base
Type string `db:"type" json:"type"`
Name null.String `db:"name" json:"name"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
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"`
}
type ListRole struct {
Base
Name null.String `db:"name" json:"name"`
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"`
}
// HasPerm checks if the user has a specific permission.
func (u *User) HasPerm(perm string) bool {
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true
}
_, ok := u.PermissionsMap[perm]
return ok
}
// HasListPerm checks if the user has get or manage access to the given list.
// perm is either PermGet or PermManage.
func (u *User) HasListPerm(types PermType, listIDs ...int) error {
var permAll, perm string
if types == 0 {
return ErrPermDenied
}
if types&PermTypeGet != 0 {
permAll = PermListGetAll
perm = PermListGet
} else if types&PermTypeManage != 0 {
permAll = PermListManageAll
perm = PermListManage
}
// Check if the user has permissions for all lists or the specific list.
if u.HasPerm(permAll) {
return nil
}
for _, id := range listIDs {
if id > 0 {
if !u.hasListPerm(perm, id) {
return ErrPermDenied
}
}
}
return nil
}
func (u *User) hasListPerm(perm string, listID int) bool {
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true
}
if _, ok := u.ListPermissionsMap[listID]; !ok {
return false
}
_, ok := u.ListPermissionsMap[listID][perm]
return ok
}
// GetPermittedLists returns a list of IDs the user has access to based on
// the given get / manage permissions. If the user has the blanket "*_all"
// permission (or the user is a super admin), then the bool is set to true and
// the list is nil as all lists are permitted.
func (u *User) GetPermittedLists(types PermType) (bool, []int) {
if types == 0 {
return false, nil
}
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true, nil
}
var (
get = types&PermTypeGet != 0
manage = types&PermTypeManage != 0
)
// If the user has the list:get_all or list:manage_all permission, no
// further checks are required.
if get {
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
return true, nil
}
}
if manage {
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
return true, nil
}
}
if get {
// If the user has per-list permissions, return that. Otherwise, let the
// 'manage' permission check run.
if len(u.GetListIDs) > 0 {
out := make([]int, len(u.GetListIDs))
copy(out, u.GetListIDs)
return false, out
}
}
if manage {
// User has per-list permissions.
out := make([]int, len(u.ManageListIDs))
copy(out, u.ManageListIDs)
return false, out
}
return false, nil
}
// FilterListsByPerm returns list IDs filtered by either of the given perms.
func (u *User) FilterListsByPerm(types PermType, listIDs []int) []int {
if types == 0 {
return nil
}
var (
get = types&PermTypeGet != 0
manage = types&PermTypeManage != 0
)
// If the user has full list management permission,
// no further checks are required.
if get {
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
return listIDs
}
}
if manage {
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
return listIDs
}
}
out := make([]int, 0, len(listIDs))
for _, id := range listIDs {
// Check if it exists in the map.
if l, ok := u.ListPermissionsMap[id]; ok {
// Check if any of the given permission exists for it.
if get {
if _, ok := l[PermListGet]; ok {
out = append(out, id)
}
} else if manage {
if _, ok := l[PermListManage]; ok {
out = append(out, id)
}
}
}
}
return out
}
// GetPermittedListIDs filters the given list IDs by the user's get/manage
// permissions and returns the filtered set. Unlike `FilterListsByPerm()`, if no
// IDs are present (empty input or 0 permitted lists), it falls back to the user's
// permitted list IDs if any. This is useful for endpoints which accept a few IDs
// or the lack of which implies "all".
func (u *User) GetPermittedListIDs(listIDs []int) []int {
listIDs = u.FilterListsByPerm(PermTypeGet|PermTypeManage, listIDs)
if len(listIDs) == 0 {
if _, ok := u.PermissionsMap[PermSubscribersGetAll]; !ok {
if len(u.GetListIDs) > 0 {
return u.GetListIDs
}
return []int{-1}
}
}
return listIDs
}
+157
View File
@@ -0,0 +1,157 @@
package bounce
import (
"errors"
"log"
"time"
"github.com/jmoiron/sqlx"
"source.offmarket.win/aleagle/eaglecast/internal/bounce/mailbox"
"source.offmarket.win/aleagle/eaglecast/internal/bounce/webhooks"
"source.offmarket.win/aleagle/eaglecast/models"
)
// Mailbox represents a POP/IMAP mailbox client that can scan messages and pass
// them to a given channel.
type Mailbox interface {
Scan(limit int, ch chan models.Bounce) error
}
// Opt represents bounce processing options.
type Opt struct {
MailboxEnabled bool `json:"mailbox_enabled"`
MailboxType string `json:"mailbox_type"`
Mailbox mailbox.Opt `json:"mailbox"`
WebhooksEnabled bool `json:"webhooks_enabled"`
SESEnabled bool `json:"ses_enabled"`
AzureEnabled bool `json:"azure_enabled"`
AzureSharedSecret string `json:"azure_shared_secret"`
AzureSharedSecretHeader string `json:"azure_shared_secret_header"`
SendgridEnabled bool `json:"sendgrid_enabled"`
SendgridKey string `json:"sendgrid_key"`
Postmark struct {
Enabled bool
Username string
Password string
}
ForwardEmail struct {
Enabled bool
Key string
}
Lettermint struct {
Enabled bool
Key string
}
RecordBounceCB func(models.Bounce) error
}
// Manager handles e-mail bounces.
type Manager struct {
queue chan models.Bounce
mailbox Mailbox
SES *webhooks.SES
Azure *webhooks.Azure
Sendgrid *webhooks.Sendgrid
Postmark *webhooks.Postmark
Forwardemail *webhooks.Forwardemail
Lettermint *webhooks.Lettermint
queries *Queries
opt Opt
log *log.Logger
}
// Queries contains the queries.
type Queries struct {
DB *sqlx.DB
RecordQuery *sqlx.Stmt
}
// New returns a new instance of the bounce manager.
func New(opt Opt, q *Queries, lo *log.Logger) (*Manager, error) {
m := &Manager{
opt: opt,
queries: q,
queue: make(chan models.Bounce, 1000),
log: lo,
}
// Is there a mailbox?
if opt.MailboxEnabled {
switch opt.MailboxType {
case "pop":
m.mailbox = mailbox.NewPOP(opt.Mailbox, lo)
default:
return nil, errors.New("unknown bounce mailbox type")
}
}
if opt.WebhooksEnabled {
if opt.SESEnabled {
m.SES = webhooks.NewSES()
}
if opt.AzureEnabled {
m.Azure = webhooks.NewAzure(opt.AzureSharedSecret, opt.AzureSharedSecretHeader)
}
if opt.SendgridEnabled {
sg, err := webhooks.NewSendgrid(opt.SendgridKey)
if err != nil {
lo.Printf("error initializing sendgrid webhooks: %v", err)
} else {
m.Sendgrid = sg
}
}
if opt.Postmark.Enabled {
m.Postmark = webhooks.NewPostmark(opt.Postmark.Username, opt.Postmark.Password)
}
if opt.ForwardEmail.Enabled {
fe := webhooks.NewForwardemail([]byte(opt.ForwardEmail.Key))
m.Forwardemail = fe
}
if opt.Lettermint.Enabled {
m.Lettermint = webhooks.NewLettermint([]byte(opt.Lettermint.Key))
}
}
return m, nil
}
// Run is a blocking function that listens for bounce events from webhooks and or mailboxes
// and executes them on the DB.
func (m *Manager) Run() {
if m.opt.MailboxEnabled {
go m.runMailboxScanner()
}
for b := range m.queue {
if b.CreatedAt.IsZero() {
b.CreatedAt = time.Now()
}
if err := m.opt.RecordBounceCB(b); err != nil {
continue
}
}
}
// runMailboxScanner runs a blocking loop that scans the mailbox at given intervals.
func (m *Manager) runMailboxScanner() {
for {
m.log.Printf("scanning bounce mailbox %s", m.opt.Mailbox.Host)
if err := m.mailbox.Scan(1000, m.queue); err != nil {
m.log.Printf("error scanning bounce mailbox: %v", err)
}
time.Sleep(m.opt.Mailbox.ScanInterval)
}
}
// Record records a new bounce event given the subscriber's email or UUID.
func (m *Manager) Record(b models.Bounce) error {
m.queue <- b
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package mailbox
import "time"
// Opt represents an e-mail POP/IMAP mailbox configuration.
type Opt struct {
// Host is the server's hostname.
Host string `json:"host"`
// Port is the server port.
Port int `json:"port"`
AuthProtocol string `json:"auth_protocol"`
// Username is the mail server login username.
Username string `json:"username"`
// Password is the mail server login password.
Password string `json:"password"`
// Folder is the name of the IMAP folder to scan for e-mails.
Folder string `json:"folder"`
// Optional TLS settings.
TLSEnabled bool `json:"tls_enabled"`
TLSSkipVerify bool `json:"tls_skip_verify"`
ScanInterval time.Duration `json:"scan_interval"`
}
+240
View File
@@ -0,0 +1,240 @@
package mailbox
import (
"encoding/json"
"fmt"
"io"
"log"
"regexp"
"strings"
"time"
"github.com/emersion/go-message"
_ "github.com/emersion/go-message/charset"
"github.com/knadh/go-pop3"
"source.offmarket.win/aleagle/eaglecast/models"
)
// POP represents a POP mailbox.
type POP struct {
opt Opt
client *pop3.Client
lo *log.Logger
}
type bounceHeaders struct {
Header string
Regexp *regexp.Regexp
}
type bounceMeta struct {
From string `json:"from"`
Subject string `json:"subject"`
MessageID string `json:"message_id"`
DeliveredTo string `json:"delivered_to"`
Received []string `json:"received"`
ClassifyReason string `json:"classify_reason"`
}
var (
// List of header to look for in the e-mail body, regexp to fall back to if the header is empty.
headerLookups = []bounceHeaders{
{models.EmailHeaderCampaignUUID, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderCampaignUUID + `:\s+?)([a-z0-9\-]{36})`)},
{models.EmailHeaderSubscriberUUID, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderSubscriberUUID + `:\s+?)([a-z0-9\-]{36})`)},
{models.EmailHeaderDate, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderDate + `:\s+?)([\w,\,\ ,:,+,-]*(?:\(?:\w*\))?)`)},
{models.EmailHeaderFrom, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderFrom + `:\s+?)(.*)`)},
{models.EmailHeaderSubject, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderSubject + `:\s+?)(.*)`)},
{models.EmailHeaderMessageId, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderMessageId + `:\s+?)(.*)`)},
{models.EmailHeaderDeliveredTo, regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderDeliveredTo + `:\s+?)(.*)`)},
}
reHdrReceived = regexp.MustCompile(`(?m)(?:^` + models.EmailHeaderReceived + `:\s+?)(.*)`)
// SMTP status code (5.x.x or 4.x.x) to classify hard/soft bounces.
reSMTPStatus = regexp.MustCompile(`(?m)(?i)^(?:Status:\s*)?(?:\d{3}\s+)?([45]\.\d+\.\d+)`)
// List of (conventional) strings to guess hard bounces.
reHardBounce = regexp.MustCompile(`(?i)(NXDOMAIN|user unknown|address not found|mailbox not found|address.*reject|does not exist|` +
`invalid recipient|no such user|recipient.*invalid|undeliverable|permanent.*failure|permanent.*error|` +
`bad.*address|unknown.*user|account.*disabled|address.*disabled)`)
)
// NewPOP returns a new instance of the POP mailbox client.
func NewPOP(opt Opt, lo *log.Logger) *POP {
return &POP{
opt: opt,
client: pop3.New(pop3.Opt{
Host: opt.Host,
Port: opt.Port,
TLSEnabled: opt.TLSEnabled,
TLSSkipVerify: opt.TLSSkipVerify,
}),
lo: lo,
}
}
// Scan scans the mailbox and pushes the downloaded messages into the given channel.
// The messages that are downloaded are deleted from the server. If limit > 0,
// all messages on the server are downloaded and deleted.
func (p *POP) Scan(limit int, ch chan models.Bounce) error {
c, err := p.client.NewConn()
if err != nil {
return err
}
defer c.Quit()
// Authenticate.
if p.opt.AuthProtocol != "none" {
if err := c.Auth(p.opt.Username, p.opt.Password); err != nil {
return err
}
}
// Get the total number of messages on the server.
count, _, err := c.Stat()
if err != nil {
return err
}
// No messages.
if count == 0 {
return nil
}
if limit > 0 && count > limit {
count = limit
}
// Download messages.
for id := 1; id <= count; id++ {
// Retrieve the raw bytes of the message.
b, err := c.RetrRaw(id)
if err != nil {
p.lo.Printf("error retrieving bounce message %d: %v", id, err)
continue
}
// Parse the message.
m, err := message.Read(b)
if err != nil {
p.lo.Printf("error parsing bounce message %d: %v", id, err)
continue
}
h := m
// If this is a multipart message, find the last part.
if mr := m.MultipartReader(); mr != nil {
for {
part, err := mr.NextPart()
if err == io.EOF {
break
} else if err != nil {
p.lo.Printf("error reading multipart bounce message %d: %v", id, err)
continue
}
h = part
}
}
// Reset the "unread portion" pointer of the message buffer.
// If you don't do this, you can't read the entire body because the pointer will not point to the beginning.
b, _ = c.RetrRaw(id)
// Lookup headers in the e-mail. If a header isn't found, fall back to regexp lookups.
hdr := make(map[string]string, 7)
for _, l := range headerLookups {
v := h.Header.Get(l.Header)
// Not in the header. Try regexp.
if v == "" {
if m := l.Regexp.FindAllSubmatch(b.Bytes(), -1); m != nil {
v = string(m[len(m)-1][1])
}
}
hdr[l.Header] = strings.TrimSpace(v)
}
// Received is a []string header.
msgReceived := h.Header.Map()[models.EmailHeaderReceived]
if len(msgReceived) == 0 {
if u := reHdrReceived.FindAllSubmatch(b.Bytes(), -1); u != nil {
for i := range u {
msgReceived = append(msgReceived, string(u[i][1]))
}
}
}
date, _ := time.Parse("Mon, 02 Jan 2006 15:04:05 -0700", hdr[models.EmailHeaderDate])
if date.IsZero() {
date = time.Now()
}
// Classify the bounce type based on message content.
bounceType, bounceReason := classifyBounce(b.Bytes())
// Additional bounce e-mail metadata.
meta, _ := json.Marshal(bounceMeta{
From: hdr[models.EmailHeaderFrom],
Subject: hdr[models.EmailHeaderSubject],
MessageID: hdr[models.EmailHeaderMessageId],
DeliveredTo: hdr[models.EmailHeaderDeliveredTo],
Received: msgReceived,
ClassifyReason: bounceReason,
})
select {
case ch <- models.Bounce{
Type: bounceType,
CampaignUUID: hdr[models.EmailHeaderCampaignUUID],
SubscriberUUID: hdr[models.EmailHeaderSubscriberUUID],
Source: p.opt.Host,
CreatedAt: date,
Meta: meta,
}:
default:
}
}
// Delete the downloaded messages.
for id := 1; id <= count; id++ {
if err := c.Dele(id); err != nil {
return err
}
}
return nil
}
// classifyBounce analyzes the bounce message content and determines if it's a hard or soft bounce.
// It checks SMTP status codes, diagnostic headers, and bounce keywords (using string heuristics).
// soft is the default preference.
// Returns the bounce type and a classification reason containing context about what matched.
func classifyBounce(b []byte) (string, string) {
if matches := reSMTPStatus.FindAllSubmatch(b, -1); matches != nil {
for _, m := range matches {
if len(m) >= 2 && len(m[0]) > 1 {
// Full status code (e.g., "5.1.1").
status := m[1]
// 5.x.x is hard bounce.
if status[0] == '5' {
return models.BounceTypeHard, fmt.Sprintf("smtp_status=%s", status)
}
// 4.x.x is soft bounce.
if status[0] == '4' {
return models.BounceTypeSoft, fmt.Sprintf("smtp_status=%s", status)
}
}
}
}
// Check for explicit hard bounce keywords.
if match := reHardBounce.FindSubmatch(b); match != nil {
return models.BounceTypeHard, fmt.Sprintf("body_match=%s", match[1])
}
return models.BounceTypeSoft, "default"
}
+241
View File
@@ -0,0 +1,241 @@
package webhooks
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
var reSMTPStatus = regexp.MustCompile(`\b([245]\.\d\.\d)\b`)
type azureEvent struct {
EventType string `json:"eventType"`
Data azureEventData `json:"data"`
RawData json.RawMessage `json:"-"`
}
type azureEventData struct {
Recipient string `json:"recipient"`
MessageID string `json:"messageId"`
InternetMessageID string `json:"internetMessageId"`
Status string `json:"status"`
DeliveryStatusDetails azureDeliveryStatus `json:"deliveryStatusDetails"`
DeliveryAttemptTimestamp *time.Time `json:"deliveryAttemptTimestamp"`
DeliveryAttemptTimeStamp *time.Time `json:"deliveryAttemptTimeStamp"`
AdditionalData map[string]json.RawMessage `json:"-"`
}
type azureDeliveryStatus struct {
StatusMessage string `json:"statusMessage"`
}
// Azure handles webhook notifications from Azure Communication Services through Event Grid.
type Azure struct {
sharedSecret string
sharedSecretHeader string
}
// NewAzure returns a new Azure webhook handler.
func NewAzure(sharedSecret, sharedSecretHeader string) *Azure {
return &Azure{
sharedSecret: strings.TrimSpace(sharedSecret),
sharedSecretHeader: strings.TrimSpace(sharedSecretHeader),
}
}
// ProcessSubscription processes Event Grid subscription validation requests and
// returns the response payload that should be written to HTTP response body.
func (a *Azure) ProcessSubscription(b []byte) (json.RawMessage, error) {
events, err := parseAzureEvents(b)
if err != nil {
return nil, err
}
if len(events) == 0 {
return nil, errors.New("empty event payload")
}
// Validation code arrives in the first event for subscription validation flow.
var payload map[string]json.RawMessage
if err := json.Unmarshal(events[0].RawData, &payload); err != nil {
return nil, fmt.Errorf("error reading validation payload: %v", err)
}
rawData, ok := payload["data"]
if !ok {
return nil, errors.New("missing event data")
}
var data map[string]string
if err := json.Unmarshal(rawData, &data); err != nil {
return nil, fmt.Errorf("error reading validation data: %v", err)
}
code := strings.TrimSpace(data["validationCode"])
if code == "" {
return nil, errors.New("missing validationCode in subscription payload")
}
res, _ := json.Marshal(map[string]string{
"validationResponse": code,
})
return json.RawMessage(res), nil
}
// ProcessBounce parses Azure Event Grid email delivery events and returns bounce entries.
func (a *Azure) ProcessBounce(req *http.Request, b []byte) ([]models.Bounce, error) {
if err := a.verifyAuth(req); err != nil {
return nil, err
}
events, err := parseAzureEvents(b)
if err != nil {
return nil, err
}
out := make([]models.Bounce, 0, len(events))
for _, ev := range events {
// Ignore non-delivery report events.
if ev.EventType != "Microsoft.Communication.EmailDeliveryReportReceived" {
continue
}
typ, ok := mapAzureStatus(ev.Data.Status, ev.Data.DeliveryStatusDetails.StatusMessage)
if !ok {
continue
}
email := strings.ToLower(strings.TrimSpace(ev.Data.Recipient))
if email == "" {
continue
}
tstamp := ev.Data.DeliveryAttemptTimestamp
if tstamp == nil {
tstamp = ev.Data.DeliveryAttemptTimeStamp
}
createdAt := time.Now()
if tstamp != nil && !tstamp.IsZero() {
createdAt = *tstamp
}
out = append(out, models.Bounce{
Email: email,
Type: typ,
Meta: json.RawMessage(ev.RawData),
Source: "azure",
CreatedAt: createdAt,
})
}
return out, nil
}
func (a *Azure) verifyAuth(req *http.Request) error {
const (
defaultSecretHeader = "X-EagleCast-Webhook-Secret"
querySecretParam = "code"
)
// If no local credential is configured, allow webhook payloads.
if a.sharedSecret == "" {
return nil
}
if req == nil {
return errors.New("missing azure event grid request context")
}
querySecret := strings.TrimSpace(req.URL.Query().Get(querySecretParam))
if secretsEqual(a.sharedSecret, querySecret) {
return nil
}
headerName := a.sharedSecretHeader
if headerName == "" {
headerName = defaultSecretHeader
}
headerSecret := strings.TrimSpace(req.Header.Get(headerName))
if secretsEqual(a.sharedSecret, headerSecret) {
return nil
}
return errors.New("invalid azure event grid shared secret")
}
func secretsEqual(expected, given string) bool {
if expected == "" || given == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(given)) == 1
}
func parseAzureEvents(b []byte) ([]azureEvent, error) {
var raws []json.RawMessage
if err := json.Unmarshal(b, &raws); err != nil {
return nil, fmt.Errorf("error unmarshalling azure notification array: %v", err)
}
events := make([]azureEvent, 0, len(raws))
for _, raw := range raws {
ev := azureEvent{RawData: raw}
if err := json.Unmarshal(raw, &ev); err != nil {
return nil, fmt.Errorf("error unmarshalling azure event: %v", err)
}
events = append(events, ev)
}
return events, nil
}
func mapAzureStatus(status, details string) (string, bool) {
s := strings.ToLower(strings.TrimSpace(status))
d := strings.ToLower(strings.TrimSpace(details))
switch s {
case "bounced", "suppressed":
return models.BounceTypeHard, true
case "failed":
// Infer severity from SMTP-enhanced status if available.
if m := reSMTPStatus.FindStringSubmatch(d); len(m) > 1 {
if strings.HasPrefix(m[1], "5.") {
return models.BounceTypeHard, true
}
if strings.HasPrefix(m[1], "4.") {
return models.BounceTypeSoft, true
}
}
if strings.Contains(d, "mailbox full") ||
strings.Contains(d, "temporar") ||
strings.Contains(d, "timeout") ||
strings.Contains(d, "rate limit") ||
strings.Contains(d, "throttle") {
return models.BounceTypeSoft, true
}
if strings.Contains(d, "invalid") ||
strings.Contains(d, "does not exist") ||
strings.Contains(d, "unknown user") ||
strings.Contains(d, "domain") {
return models.BounceTypeHard, true
}
return models.BounceTypeSoft, true
case "filteredspam", "quarantined":
if strings.Contains(d, "spam complaint") ||
strings.Contains(d, "complaint") ||
strings.Contains(d, "abuse") {
return models.BounceTypeComplaint, true
}
return models.BounceTypeSoft, true
default:
return "", false
}
}
+99
View File
@@ -0,0 +1,99 @@
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
type BounceDetails struct {
Action string `json:"action"`
Message string `json:"message"`
Category string `json:"category"`
Code int `json:"code"`
Status any `json:"status"`
Line int `json:"line"`
}
type forwardemailNotif struct {
EmailID string `json:"email_id"`
ListID string `json:"list_id"`
ListUnsubscribe string `json:"list_unsubscribe"`
FeedbackID string `json:"feedback_id"`
Recipient string `json:"recipient"`
Message string `json:"message"`
Response string `json:"response"`
ResponseCode int `json:"response_code"`
TruthSource string `json:"truth_source"`
Headers map[string]string `json:"headers"`
Bounce BounceDetails `json:"bounce"`
BouncedAt time.Time `json:"bounced_at"`
}
// Forwardemail handles webhook notifications (mainly bounce notifications).
type Forwardemail struct {
hmacKey []byte
}
func NewForwardemail(key []byte) *Forwardemail {
return &Forwardemail{hmacKey: key}
}
func (p *Forwardemail) ProcessBounce(sigHex string, body []byte) ([]models.Bounce, error) {
if len(p.hmacKey) == 0 {
return nil, errors.New("webhook key is not configured")
}
// Decode the hex-encoded signature from the webhook
sig, err := hex.DecodeString(sigHex)
if err != nil {
return nil, fmt.Errorf("invalid signature encoding: %v", err)
}
// Generate HMAC using the request body and secret key
mac := hmac.New(sha256.New, p.hmacKey)
mac.Write(body)
expectedSignature := mac.Sum(nil)
// Compare the generated signature with the provided signature
if !hmac.Equal(expectedSignature, sig) {
return nil, errors.New("invalid signature")
}
// Parse the JSON payload
var n forwardemailNotif
if err := json.Unmarshal(body, &n); err != nil {
return nil, fmt.Errorf("error unmarshalling Forwardemail notification: %v", err)
}
// Categorize the bounce type
typ := models.BounceTypeSoft
hardBounceCategories := []string{"block", "recipient", "virus", "spam"}
for _, category := range hardBounceCategories {
if n.Bounce.Category == category {
typ = models.BounceTypeHard
break
}
}
campUUID := ""
if v, ok := n.Headers["X-EagleCast-Campaign"]; ok {
campUUID = v
}
return []models.Bounce{{
Email: strings.ToLower(n.Recipient),
CampaignUUID: campUUID,
Type: typ,
Source: "forwardemail",
Meta: json.RawMessage(body),
CreatedAt: n.BouncedAt,
}}, nil
}
+146
View File
@@ -0,0 +1,146 @@
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
type lettermintNotif struct {
ID string `json:"id"`
Event string `json:"event"`
Timestamp string `json:"timestamp"`
Data struct {
MessageID string `json:"message_id"`
Subject string `json:"subject"`
Recipient string `json:"recipient"`
Response *struct {
StatusCode int `json:"status_code"`
EnhancedStatus string `json:"enhanced_status_code"`
Content string `json:"content"`
} `json:"response"`
Metadata json.RawMessage `json:"metadata"`
Tag string `json:"tag"`
} `json:"data"`
}
// Lettermint handles bounce webhook notifications from Lettermint.
type Lettermint struct {
hmacKey []byte
}
// NewLettermint returns a new Lettermint webhook handler.
func NewLettermint(key []byte) *Lettermint {
return &Lettermint{hmacKey: key}
}
// ProcessBounce processes an incoming Lettermint webhook payload and returns a bounce object.
func (l *Lettermint) ProcessBounce(sig string, body []byte) ([]models.Bounce, error) {
if len(l.hmacKey) == 0 {
return nil, fmt.Errorf("webhook key is not configured")
}
// Parse the signature header: t={timestamp},v1={hex_signature}.
ts, sigHex, err := parseLettermintSignature(sig)
if err != nil {
return nil, err
}
// Verify timestamp tolerance (300 seconds).
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return nil, fmt.Errorf("signature timestamp expired")
}
// Decode the hex signature from the header.
sigB, err := hex.DecodeString(strings.TrimSpace(sigHex))
if err != nil {
return nil, fmt.Errorf("invalid signature encoding: %v", err)
}
// Compute HMAC-SHA256 of "{timestamp}.{body}" and compare.
mac := hmac.New(sha256.New, l.hmacKey)
mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body)))
if !hmac.Equal(mac.Sum(nil), sigB) {
return nil, fmt.Errorf("invalid signature")
}
var n lettermintNotif
if err := json.Unmarshal(body, &n); err != nil {
return nil, fmt.Errorf("error unmarshalling Lettermint notification: %v", err)
}
// Map event to bounce type.
var typ string
switch n.Event {
case "message.hard_bounced":
typ = models.BounceTypeHard
case "message.soft_bounced":
typ = models.BounceTypeSoft
case "message.spam_complaint":
typ = models.BounceTypeComplaint
default:
// Ignore irrelevant events (e.g. webhook.test).
return nil, nil
}
campUUID := ""
if len(n.Data.Metadata) > 0 {
var meta map[string]string
if err := json.Unmarshal(n.Data.Metadata, &meta); err == nil {
if v, ok := meta["X-EagleCast-Campaign"]; ok {
campUUID = v
}
}
}
t, _ := time.Parse(time.RFC3339, n.Timestamp)
if t.IsZero() {
t = time.Now()
}
return []models.Bounce{{
Email: strings.ToLower(n.Data.Recipient),
CampaignUUID: campUUID,
Type: typ,
Source: "lettermint",
Meta: json.RawMessage(body),
CreatedAt: t,
}}, nil
}
// parseLettermintSignature parses a signature header of the form "t={timestamp},v1={hex}".
func parseLettermintSignature(sig string) (int64, string, error) {
var (
ts int64
hash string
)
for _, part := range strings.Split(sig, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
if _, err := fmt.Sscanf(kv[1], "%d", &ts); err != nil {
return 0, "", fmt.Errorf("invalid timestamp in signature: %v", err)
}
case "v1":
hash = kv[1]
}
}
if ts == 0 || hash == "" {
return 0, "", fmt.Errorf("invalid signature format")
}
return ts, hash, nil
}
+119
View File
@@ -0,0 +1,119 @@
package webhooks
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type postmarkNotif struct {
RecordType string `json:"RecordType"`
MessageStream string `json:"MessageStream"`
ID int `json:"ID"`
Type string `json:"Type"`
TypeCode int `json:"TypeCode"`
Name string `json:"Name"`
Tag string `json:"Tag"`
MessageID string `json:"MessageID"`
Metadata map[string]string `json:"Metadata"`
ServerID int `json:"ServerID"`
Description string `json:"Description"`
Details string `json:"Details"`
Email string `json:"Email"`
From string `json:"From"`
BouncedAt time.Time `json:"BouncedAt"` // "2019-11-05T16:33:54.9070259Z"
DumpAvailable bool `json:"DumpAvailable"`
Inactive bool `json:"Inactive"`
CanActivate bool `json:"CanActivate"`
Subject string `json:"Subject"`
Content string `json:"Content"`
}
// Postmark handles webhook notifications (mainly bounce notifications).
type Postmark struct {
authHandler echo.HandlerFunc
}
func NewPostmark(username, password string) *Postmark {
return &Postmark{
authHandler: middleware.BasicAuth(makePostmarkAuthHandler(username, password))(func(c echo.Context) error {
return nil
}),
}
}
// ProcessBounce processes Postmark bounce notifications and returns one object.
func (p *Postmark) ProcessBounce(b []byte, c echo.Context) ([]models.Bounce, error) {
// Do basicauth.
if err := p.authHandler(c); err != nil {
return nil, err
}
var n postmarkNotif
if err := json.Unmarshal(b, &n); err != nil {
return nil, fmt.Errorf("error unmarshalling postmark notification: %v", err)
}
// Ignore irrelevant messages.
if n.RecordType != "Bounce" && n.RecordType != "SpamComplaint" {
return nil, nil
}
supportedBounceType := true
typ := models.BounceTypeHard
switch n.Type {
case "HardBounce", "BadEmailAddress", "ManuallyDeactivated":
typ = models.BounceTypeHard
case "SoftBounce", "Transient", "DnsError", "SpamNotification", "VirusNotification", "DMARCPolicy":
typ = models.BounceTypeSoft
case "SpamComplaint":
typ = models.BounceTypeComplaint
default:
supportedBounceType = false
}
if !supportedBounceType {
return nil, fmt.Errorf("unsupported bounce type: %v", n.Type)
}
// Look for the campaign ID in headers.
campUUID := ""
if v, ok := n.Metadata["X-EagleCast-Campaign"]; ok {
campUUID = v
}
return []models.Bounce{{
Email: strings.ToLower(n.Email),
CampaignUUID: campUUID,
Type: typ,
Source: "postmark",
Meta: json.RawMessage(b),
CreatedAt: n.BouncedAt,
}}, nil
}
func makePostmarkAuthHandler(cfgUser, cfgPassword string) func(username, password string, c echo.Context) (bool, error) {
var (
u = []byte(cfgUser)
p = []byte(cfgPassword)
)
return func(username, password string, c echo.Context) (bool, error) {
if len(u) == 0 || len(p) == 0 {
return false, errors.New("webhook credentials are not configured")
}
if subtle.ConstantTimeCompare([]byte(username), u) == 1 && subtle.ConstantTimeCompare([]byte(password), p) == 1 {
return true, nil
}
return false, nil
}
}
+115
View File
@@ -0,0 +1,115 @@
package webhooks
import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
type sendgridNotif struct {
Email string `json:"email"`
Timestamp int64 `json:"timestamp"`
Event string `json:"event"`
BounceClassification string `json:"bounce_classification"`
// SendGrid flattens all X-headers and adds them to the bounce
// event notification.
CampaignUUID string `json:"XEagleCastCampaign"`
}
// Sendgrid handles Sendgrid/SNS webhook notifications including confirming SNS topic subscription
// requests and bounce notifications.
type Sendgrid struct {
pubKey *ecdsa.PublicKey
}
// NewSendgrid returns a new Sendgrid instance.
func NewSendgrid(key string) (*Sendgrid, error) {
// Get the certificate from the key.
sigB, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return nil, err
}
pubKey, err := x509.ParsePKIXPublicKey(sigB)
if err != nil {
return nil, err
}
return &Sendgrid{pubKey: pubKey.(*ecdsa.PublicKey)}, nil
}
// ProcessBounce processes Sendgrid bounce notifications and returns one or more Bounce objects.
func (s *Sendgrid) ProcessBounce(sig, timestamp string, b []byte) ([]models.Bounce, error) {
if err := s.verifyNotif(sig, timestamp, b); err != nil {
return nil, err
}
var notifs []sendgridNotif
if err := json.Unmarshal(b, &notifs); err != nil {
return nil, fmt.Errorf("error unmarshalling Sendgrid notification: %v", err)
}
out := make([]models.Bounce, 0, len(notifs))
for _, n := range notifs {
if n.Event != "bounce" {
continue
}
typ := models.BounceTypeHard
if n.BounceClassification == "technical" || n.BounceClassification == "content" {
typ = models.BounceTypeSoft
}
tstamp := time.Unix(n.Timestamp, 0)
bn := models.Bounce{
CampaignUUID: n.CampaignUUID,
Email: strings.ToLower(n.Email),
Type: typ,
Meta: json.RawMessage(b),
Source: "sendgrid",
CreatedAt: tstamp,
}
out = append(out, bn)
}
return out, nil
}
// verifyNotif verifies the signature on a notification payload.
func (s *Sendgrid) verifyNotif(sig, timestamp string, b []byte) error {
sigB, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
return err
}
ecdsaSig := struct {
R *big.Int
S *big.Int
}{}
if _, err := asn1.Unmarshal(sigB, &ecdsaSig); err != nil {
return fmt.Errorf("error asn1 unmarshal of signature: %v", err)
}
h := sha256.New()
h.Write([]byte(timestamp))
h.Write(b)
hash := h.Sum(nil)
if !ecdsa.Verify(s.pubKey, hash, ecdsaSig.R, ecdsaSig.S) {
return errors.New("invalid signature")
}
return nil
}
+283
View File
@@ -0,0 +1,283 @@
package webhooks
import (
"bytes"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
// AWS signature/validation logic borrowed from @cavnit's contrib:
// https://gist.github.com/cavnit/f4d63ba52b3aa05406c07dcbca2ca6cf
// https://sns.ap-southeast-1.amazonaws.com/SimpleNotificationService-010a507c1833636cd94bdb98bd93083a.pem
var sesRegCertURL = regexp.MustCompile(`(?i)^https://sns\.[a-z0-9\-]+\.amazonaws\.com(\.cn)?/SimpleNotificationService\-[a-z0-9]+\.pem$`)
// sesNotif is an individual notification wrapper posted by SNS.
type sesNotif struct {
// Message may be a plaintext message or a stringified JSON payload based on the message type.
// Four SES messages, this is the actual payload.
Message string `json:"Message"`
MessageId string `json:"MessageId"`
Signature string `json:"Signature"`
SignatureVersion string `json:"SignatureVersion"`
SigningCertURL string `json:"SigningCertURL"`
Subject string `json:"Subject"`
Timestamp string `json:"Timestamp"`
Token string `json:"Token"`
TopicArn string `json:"TopicArn"`
Type string `json:"Type"`
SubscribeURL string `json:"SubscribeURL"`
UnsubscribeURL string `json:"UnsubscribeURL"`
}
type sesTimestamp time.Time
type sesMail struct {
EventType string `json:"eventType"`
NotifType string `json:"notificationType"`
Bounce struct {
BounceType string `json:"bounceType"`
BouncedRecipients []struct {
Status string `json:"status"`
} `json:"bouncedRecipients"`
} `json:"bounce"`
Mail struct {
Timestamp sesTimestamp `json:"timestamp"`
HeadersTruncated bool `json:"headersTruncated"`
Destination []string `json:"destination"`
Headers []map[string]string `json:"headers"`
} `json:"mail"`
}
// SES handles SES/SNS webhook notifications including confirming SNS topic subscription
// requests and bounce notifications.
type SES struct {
mu sync.RWMutex
certs map[string]*x509.Certificate
}
// NewSES returns a new SES instance.
func NewSES() *SES {
return &SES{
certs: make(map[string]*x509.Certificate),
}
}
// ProcessSubscription processes an SNS topic subscribe / unsubscribe notification
// by parsing and verifying the payload and calling the subscribe / unsubscribe URL.
func (s *SES) ProcessSubscription(b []byte) error {
var n sesNotif
if err := json.Unmarshal(b, &n); err != nil {
return fmt.Errorf("error unmarshalling SNS notification: %v", err)
}
if err := s.verifyNotif(n); err != nil {
return err
}
// Make an HTTP request to the sub/unsub URL.
u := n.SubscribeURL
if n.Type == "UnsubscriptionConfirmation" {
u = n.UnsubscribeURL
}
resp, err := http.Get(u)
if err != nil {
return fmt.Errorf("error requesting subscription URL: %v", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("non 200 response on subscription URL: %v", resp.StatusCode)
}
return nil
}
// ProcessBounce processes an SES bounce notification and returns a Bounce object.
func (s *SES) ProcessBounce(b []byte) (models.Bounce, error) {
var (
bounce models.Bounce
n sesNotif
)
if err := json.Unmarshal(b, &n); err != nil {
return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
}
if err := s.verifyNotif(n); err != nil {
return bounce, err
}
var m sesMail
if err := json.Unmarshal([]byte(n.Message), &m); err != nil {
return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
}
if (m.EventType != "" && m.EventType != "Bounce") ||
(m.NotifType != "" && (m.NotifType != "Bounce" && m.NotifType != "Complaint")) {
return bounce, errors.New("notification type is not bounce")
}
if len(m.Mail.Destination) == 0 {
return bounce, errors.New("no destination e-mails found in SES notification")
}
typ := models.BounceTypeSoft
if m.Bounce.BounceType == "Permanent" {
typ = models.BounceTypeHard
}
if m.Bounce.BounceType == "Transient" && len(m.Bounce.BouncedRecipients) > 0 {
// "Invalid domain" bounce.
if m.Bounce.BouncedRecipients[0].Status == "5.4.4" {
typ = models.BounceTypeHard
}
}
if m.NotifType == "Complaint" {
typ = models.BounceTypeComplaint
}
// Look for the campaign ID in headers.
campUUID := ""
if !m.Mail.HeadersTruncated {
for _, h := range m.Mail.Headers {
key, ok := h["name"]
if !ok || key != models.EmailHeaderCampaignUUID {
continue
}
campUUID, ok = h["value"]
if !ok {
continue
}
break
}
}
return models.Bounce{
Email: strings.ToLower(m.Mail.Destination[0]),
CampaignUUID: campUUID,
Type: typ,
Source: "ses",
Meta: json.RawMessage(n.Message),
CreatedAt: time.Time(m.Mail.Timestamp),
}, nil
}
func (s *SES) buildSignature(n sesNotif) []byte {
var b bytes.Buffer
b.WriteString("Message" + "\n" + n.Message + "\n")
b.WriteString("MessageId" + "\n" + n.MessageId + "\n")
if n.Subject != "" {
b.WriteString("Subject" + "\n" + n.Subject + "\n")
}
if n.SubscribeURL != "" {
b.WriteString("SubscribeURL" + "\n" + n.SubscribeURL + "\n")
}
b.WriteString("Timestamp" + "\n" + n.Timestamp + "\n")
if n.Token != "" {
b.WriteString("Token" + "\n" + n.Token + "\n")
}
b.WriteString("TopicArn" + "\n" + n.TopicArn + "\n")
b.WriteString("Type" + "\n" + n.Type + "\n")
return b.Bytes()
}
// verifyNotif verifies the signature on a notification payload.
func (s *SES) verifyNotif(n sesNotif) error {
// Get the message signing certificate.
cert, err := s.getCert(n.SigningCertURL)
if err != nil {
return fmt.Errorf("error getting SNS cert: %v", err)
}
sign, err := base64.StdEncoding.DecodeString(n.Signature)
if err != nil {
return err
}
return cert.CheckSignature(x509.SHA1WithRSA, s.buildSignature(n), sign)
}
// getCert takes the SNS certificate URL and fetches it and caches it for the first time,
// and returns the cached cert for subsequent calls.
func (s *SES) getCert(certURL string) (*x509.Certificate, error) {
// Ensure that the cert URL is Amazon's.
u, err := url.Parse(certURL)
if err != nil {
return nil, err
}
if !sesRegCertURL.MatchString(certURL) {
return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
}
// Return if it's cached.
s.mu.RLock()
c, ok := s.certs[u.Path]
s.mu.RUnlock()
if ok {
return c, nil
}
// Fetch the certificate.
resp, err := http.Get(certURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
p, _ := pem.Decode(body)
if p == nil {
return nil, errors.New("invalid PEM")
}
cert, err := x509.ParseCertificate(p.Bytes)
// Cache the cert in-memory.
s.mu.Lock()
// Check again if another goroutine already cached it while we were fetching.
if c2, ok := s.certs[u.Path]; ok && c2 != nil {
s.mu.Unlock()
// Return the cached cert and ignore this goroutine's parse error (if any).
return c2, nil
}
// Only cache when parsing succeeded (don't cache nil certs from failures).
if err == nil {
s.certs[u.Path] = cert
}
s.mu.Unlock()
return cert, err
}
func (st *sesTimestamp) UnmarshalJSON(b []byte) error {
t, err := time.Parse("2006-01-02T15:04:05.999999999Z", strings.Trim(string(b), `"`))
if err != nil {
return err
}
*st = sesTimestamp(t)
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package buflog
import (
"bytes"
"strings"
"sync"
)
// BufLog implements a simple log buffer that can be supplied to a std
// log instance. It stores logs up to N lines.
type BufLog struct {
maxLines int
buf *bytes.Buffer
lines []string
sync.RWMutex
}
// New returns a new log buffer that stores up to maxLines lines.
func New(maxLines int) *BufLog {
return &BufLog{
maxLines: maxLines,
buf: &bytes.Buffer{},
lines: make([]string, 0, maxLines),
}
}
// Write writes a log item to the buffer maintaining maxLines capacity
// using LIFO.
func (bu *BufLog) Write(b []byte) (n int, err error) {
bu.Lock()
defer bu.Unlock()
if len(bu.lines) >= bu.maxLines {
bu.lines[0] = ""
bu.lines = bu.lines[1:len(bu.lines)]
}
bu.lines = append(bu.lines, strings.TrimSpace(string(b)))
return len(b), nil
}
// Lines returns the log lines.
func (bu *BufLog) Lines() []string {
bu.RLock()
defer bu.RUnlock()
out := make([]string, len(bu.lines))
copy(out[:], bu.lines[:])
return out
}
+204
View File
@@ -0,0 +1,204 @@
package captcha
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/altcha-org/altcha-lib-go"
"source.offmarket.win/aleagle/eaglecast/internal/tmptokens"
)
const (
hCaptchaURL = "https://hcaptcha.com/siteverify"
)
type hCaptchaResp struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error_codes"`
}
const (
ProviderNone = ""
ProviderHCaptcha = "hcaptcha"
ProviderAltcha = "altcha"
)
// Captcha is a captcha client supporting multiple providers.
type Captcha struct {
provider string
hCaptcha hCaptchaOpt
altcha altchaOpt
client *http.Client
}
type Opt struct {
HCaptcha struct {
Enabled bool `json:"enabled"`
Key string `json:"key"`
Secret string `json:"secret"`
} `json:"hcaptcha"`
Altcha struct {
Enabled bool `json:"enabled"`
Complexity int `json:"complexity"`
} `json:"altcha"`
}
type hCaptchaOpt struct {
Secret string
}
type altchaOpt struct {
Complexity int
HMACKey string
}
// New returns a new instance of the CAPTCHA client.
func New(o Opt) *Captcha {
timeout := time.Second * 5
c := &Captcha{
client: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 100,
ResponseHeaderTimeout: timeout,
IdleConnTimeout: timeout,
},
},
}
// Determine which provider is enabled
if o.Altcha.Enabled {
c.provider = ProviderAltcha
// Generate an random HMAC key for Altcha.
b := make([]byte, 24) // 24 bytes will give 32 characters when base64 encoded
_, err := rand.Read(b)
if err != nil {
panic(fmt.Sprintf("error generating Altcha HMAC key: %v", err))
}
hmacKey := base64.URLEncoding.EncodeToString(b)[:32]
c.altcha = altchaOpt{
Complexity: o.Altcha.Complexity,
HMACKey: hmacKey,
}
} else if o.HCaptcha.Enabled {
c.provider = ProviderHCaptcha
c.hCaptcha = hCaptchaOpt{
Secret: o.HCaptcha.Secret,
}
}
return c
}
// IsEnabled returns true if any captcha provider is enabled.
func (c *Captcha) IsEnabled() bool {
return c.provider != ProviderNone
}
// GetProvider returns the active captcha provider.
func (c *Captcha) GetProvider() string {
return c.provider
}
// GenerateChallenge generates a challenge for the active provider.
// For hCaptcha, this returns empty string as challenges are generated client-side.
// For Altcha, this returns a JSON challenge.
func (c *Captcha) GenerateChallenge() (string, error) {
switch c.provider {
case ProviderAltcha:
exp := time.Now().Add(5 * time.Minute)
challenge, err := altcha.CreateChallenge(altcha.ChallengeOptions{
Algorithm: altcha.SHA256,
MaxNumber: int64(c.altcha.Complexity),
SaltLength: 12,
HMACKey: c.altcha.HMACKey,
Expires: &exp,
})
if err != nil {
return "", fmt.Errorf("failed to create Altcha challenge: %w", err)
}
challengeJSON, err := json.Marshal(challenge)
if err != nil {
return "", fmt.Errorf("failed to marshal Altcha challenge: %w", err)
}
return string(challengeJSON), nil
case ProviderHCaptcha:
// hCaptcha generates challenges client-side.
return "", nil
default:
return "", fmt.Errorf("no captcha provider enabled")
}
}
// Verify verifies a CAPTCHA response.
func (c *Captcha) Verify(token string) (error, bool) {
switch c.provider {
case ProviderAltcha:
return c.verifyAltcha(token)
case ProviderHCaptcha:
return c.verifyHCaptcha(token)
default:
return fmt.Errorf("no captcha provider enabled"), false
}
}
// verifyHCaptcha verifies an hCaptcha response.
func (c *Captcha) verifyHCaptcha(token string) (error, bool) {
resp, err := c.client.PostForm(hCaptchaURL, url.Values{
"secret": {c.hCaptcha.Secret},
"response": {token},
})
if err != nil {
return err, false
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err, false
}
var r hCaptchaResp
if err := json.Unmarshal(body, &r); err != nil {
return err, true
}
if !r.Success {
return fmt.Errorf("hCaptcha failed: %s", strings.Join(r.ErrorCodes, ",")), false
}
return nil, true
}
// verifyAltcha verifies an Altcha response.
func (c *Captcha) verifyAltcha(payload string) (error, bool) {
valid, err := altcha.VerifySolution(payload, c.altcha.HMACKey, true)
if err != nil {
return fmt.Errorf("failed to verify captcha solution: %w", err), false
}
if !valid {
return fmt.Errorf("captcha verification failed"), false
}
// Disallow token reuse.
if _, err := tmptokens.Check(payload); err == nil {
return fmt.Errorf("captcha token already used"), false
}
tmptokens.Set(payload, 5*time.Minute, nil)
return nil, true
}
+112
View File
@@ -0,0 +1,112 @@
package core
import (
"net/http"
"strings"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
var bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at", "type"}
// QueryBounces retrieves paginated bounce entries based on the given params.
// It also returns the total number of bounce records in the DB.
func (c *Core) QueryBounces(campID, subID int, source, orderBy, order string, offset, limit int) ([]models.Bounce, int, error) {
if !strSliceContains(orderBy, bounceQuerySortFields) {
orderBy = "created_at"
}
if order != SortAsc && order != SortDesc {
order = SortDesc
}
out := []models.Bounce{}
stmt := strings.ReplaceAll(c.q.QueryBounces, "%order%", orderBy+" "+order)
if err := c.db.Select(&out, stmt, 0, campID, subID, source, offset, limit); err != nil {
c.log.Printf("error fetching bounces: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
}
total := 0
if len(out) > 0 {
total = out[0].Total
}
return out, total, nil
}
// GetBounce retrieves bounce entries based on the given params.
func (c *Core) GetBounce(id int) (models.Bounce, error) {
var out []models.Bounce
stmt := strings.ReplaceAll(c.q.QueryBounces, "%order%", "id "+SortAsc)
if err := c.db.Select(&out, stmt, id, 0, 0, "", 0, 1); err != nil {
c.log.Printf("error fetching bounces: %v", err)
return models.Bounce{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return models.Bounce{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.bounce}"))
}
return out[0], nil
}
// RecordBounce records a new bounce.
func (c *Core) RecordBounce(b models.Bounce) error {
action, ok := c.consts.BounceActions[b.Type]
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.invalidData")+": "+b.Type)
}
_, err := c.q.RecordBounce.Exec(b.SubscriberUUID,
b.Email,
b.CampaignUUID,
b.Type,
b.Source,
b.Meta,
b.CreatedAt,
action.Count,
action.Action)
if err != nil {
// Ignore the error if it complained of no subscriber.
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "subscriber_id" {
c.log.Printf("bounced subscriber (%s / %s) not found", b.SubscriberUUID, b.Email)
return nil
}
c.log.Printf("error recording bounce: %v", err)
}
return err
}
// BlocklistBouncedSubscribers blocklists all bounced subscribers.
func (c *Core) BlocklistBouncedSubscribers() error {
if _, err := c.q.BlocklistBouncedSubscribers.Exec(); err != nil {
c.log.Printf("error blocklisting bounced subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
}
return nil
}
// DeleteBounce deletes a list.
func (c *Core) DeleteBounce(id int) error {
return c.DeleteBounces([]int{id}, false)
}
// DeleteBounces deletes multiple lists.
func (c *Core) DeleteBounces(ids []int, all bool) error {
if _, err := c.q.DeleteBounces.Exec(pq.Array(ids), all); err != nil {
c.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
return nil
}
+511
View File
@@ -0,0 +1,511 @@
package core
import (
"database/sql"
"net/http"
"time"
"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
const (
CampaignAnalyticsViews = "views"
CampaignAnalyticsClicks = "clicks"
CampaignAnalyticsBounces = "bounces"
campaignTplDefault = "default"
campaignTplArchive = "archive"
)
// QueryCampaigns retrieves paginated campaigns optionally filtering them by the given arbitrary
// query expression. It also returns the total number of records in the DB.
func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy, order string, getAll bool, permittedLists []int, offset, limit int) (models.Campaigns, int, error) {
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns, campQuerySortFields)
if statuses == nil {
statuses = []string{}
}
if tags == nil {
tags = []string{}
}
// Unsafe to ignore scanning fields not present in models.Campaigns.
var out models.Campaigns
if err := c.db.Select(&out, stmt, 0, pq.StringArray(statuses), pq.StringArray(tags), queryStr, getAll, pq.Array(permittedLists), offset, limit); err != nil {
c.log.Printf("error fetching campaigns: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
for i := range out {
// Replace null tags.
if out[i].Tags == nil {
out[i].Tags = []string{}
}
}
// Lazy load stats.
if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
c.log.Printf("error fetching campaign stats: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
}
total := 0
if len(out) > 0 {
total = out[0].Total
}
return out, total, nil
}
// GetCampaign retrieves a campaign.
func (c *Core) GetCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) {
return c.getCampaign(id, uuid, archiveSlug, campaignTplDefault)
}
// GetArchivedCampaign retrieves a campaign with the archive template body.
func (c *Core) GetArchivedCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) {
out, err := c.getCampaign(id, uuid, archiveSlug, campaignTplArchive)
if err != nil {
return out, err
}
if !out.Archive {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
return out, nil
}
// getCampaign retrieves a campaign. If typlType=default, then the campaign's
// template body is returned as "template_body". If tplType="archive",
// the archive template is returned.
func (c *Core) getCampaign(id int, uuid, archiveSlug string, tplType string) (models.Campaign, error) {
// Unsafe to ignore scanning fields not present in models.Campaigns.
var uu any
if uuid != "" {
uu = uuid
}
var out models.Campaigns
if err := c.q.GetCampaign.Select(&out, id, uu, archiveSlug, tplType); err != nil {
// if err := c.db.Select(&out, stmt, 0, pq.Array([]string{}), queryStr, 0, 1); err != nil {
c.log.Printf("error fetching campaign: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
for i := 0; i < len(out); i++ {
// Replace null tags.
if out[i].Tags == nil {
out[i].Tags = []string{}
}
}
// Lazy load stats.
if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
c.log.Printf("error fetching campaign stats: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return out[0], nil
}
// GetCampaignForPreview retrieves a campaign with a template body. If the optional tplID is > 0
// that particular template is used, otherwise, the template saved on the campaign is.
func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) {
var out models.Campaign
if err := c.q.GetCampaignForPreview.Get(&out, id, tplID); err != nil {
if err == sql.ErrNoRows {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
c.log.Printf("error fetching campaign: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return out, nil
}
// GetArchivedCampaigns retrieves campaigns with a template body.
func (c *Core) GetArchivedCampaigns(offset, limit int) (models.Campaigns, int, error) {
var out models.Campaigns
if err := c.q.GetArchivedCampaigns.Select(&out, offset, limit, campaignTplArchive); err != nil {
c.log.Printf("error fetching public campaigns: %v", err)
return models.Campaigns{}, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
total := 0
if len(out) > 0 {
total = out[0].Total
}
return out, total, nil
}
// CreateCampaign creates a new campaign.
func (c *Core) CreateCampaign(o models.Campaign, listIDs []int, mediaIDs []int) (models.Campaign, error) {
uu, err := uuid.NewV4()
if err != nil {
c.log.Printf("error generating UUID: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
}
// Insert and read ID.
var newID int
if err := c.q.CreateCampaign.Get(&newID,
uu,
o.Type,
o.Name,
o.Subject,
o.FromEmail,
o.Body,
o.AltBody,
o.ContentType,
o.SendAt,
o.Headers,
o.Attribs,
pq.StringArray(normalizeTags(o.Tags)),
o.Messenger,
o.TemplateID,
pq.Array(listIDs),
o.Archive,
o.ArchiveSlug,
o.ArchiveTemplateID,
o.ArchiveMeta,
pq.Array(mediaIDs),
o.BodySource,
); err != nil {
if err == sql.ErrNoRows {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs"))
}
c.log.Printf("error creating campaign: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
out, err := c.GetCampaign(newID, "", "")
if err != nil {
return models.Campaign{}, err
}
return out, nil
}
// UpdateCampaign updates a campaign.
func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, mediaIDs []int) (models.Campaign, error) {
_, err := c.q.UpdateCampaign.Exec(id,
o.Name,
o.Subject,
o.FromEmail,
o.Body,
o.AltBody,
o.ContentType,
o.SendAt,
o.Headers,
o.Attribs,
pq.StringArray(normalizeTags(o.Tags)),
o.Messenger,
o.TemplateID,
pq.Array(listIDs),
o.Archive,
o.ArchiveSlug,
o.ArchiveTemplateID,
o.ArchiveMeta,
pq.Array(mediaIDs),
o.BodySource)
if err != nil {
c.log.Printf("error updating campaign: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
out, err := c.GetCampaign(id, "", "")
if err != nil {
return models.Campaign{}, err
}
return out, nil
}
// UpdateCampaignStatus updates a campaign's status, eg: draft to running.
func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, error) {
cm, err := c.GetCampaign(id, "", "")
if err != nil {
return models.Campaign{}, err
}
errMsg := ""
switch status {
case models.CampaignStatusDraft:
if cm.Status != models.CampaignStatusScheduled {
errMsg = c.i18n.T("campaigns.onlyScheduledAsDraft")
}
case models.CampaignStatusScheduled:
if cm.Status != models.CampaignStatusDraft && cm.Status != models.CampaignStatusPaused {
errMsg = c.i18n.T("campaigns.onlyDraftAsScheduled")
}
if !cm.SendAt.Valid {
errMsg = c.i18n.T("campaigns.needsSendAt")
}
case models.CampaignStatusRunning:
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
errMsg = c.i18n.T("campaigns.onlyPausedDraft")
}
case models.CampaignStatusPaused:
if cm.Status != models.CampaignStatusRunning {
errMsg = c.i18n.T("campaigns.onlyActivePause")
}
case models.CampaignStatusCancelled:
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
errMsg = c.i18n.T("campaigns.onlyActiveCancel")
}
}
if len(errMsg) > 0 {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, errMsg)
}
res, err := c.q.UpdateCampaignStatus.Exec(cm.ID, status)
if err != nil {
c.log.Printf("error updating campaign status: %v", err)
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
cm.Status = status
return cm, nil
}
// UpdateCampaignArchive updates a campaign's archive properties.
func (c *Core) UpdateCampaignArchive(id int, enabled bool, tplID int, meta models.JSON, archiveSlug string) error {
if _, err := c.q.UpdateCampaignArchive.Exec(id, enabled, archiveSlug, tplID, meta); err != nil {
c.log.Printf("error updating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteCampaign deletes a campaign.
func (c *Core) DeleteCampaign(id int) error {
res, err := c.q.DeleteCampaign.Exec(id)
if err != nil {
c.log.Printf("error deleting campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
return nil
}
// DeleteCampaigns deletes multiple campaigns by IDs or by query.
func (c *Core) DeleteCampaigns(ids []int, query string, hasAllPerm bool, permittedLists []int) error {
var queryStr string
if len(ids) > 0 {
queryStr = ""
} else {
queryStr = makeSearchString(query)
}
if _, err := c.q.DeleteCampaigns.Exec(pq.Array(ids), queryStr, hasAllPerm, pq.Array(permittedLists)); err != nil {
c.log.Printf("error deleting campaigns: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
}
return nil
}
// CampaignHasLists checks if a campaign has any of the given list IDs.
func (c *Core) CampaignHasLists(id int, listIDs []int) (bool, error) {
has := false
if err := c.q.CampaignHasLists.Get(&has, id, pq.Array(listIDs)); err != nil {
c.log.Printf("error checking campaign lists: %v", err)
return false, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return has, nil
}
// GetRunningCampaignStats returns the progress stats of running campaigns.
func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) {
out := []models.CampaignStats{}
if err := c.q.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
c.log.Printf("error fetching campaign stats: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} else if len(out) == 0 {
return nil, nil
}
return out, nil
}
func (c *Core) GetCampaignAnalyticsCounts(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsCount, error) {
// Pick campaign view counts or click counts.
var stmt *sqlx.Stmt
switch typ {
case "views":
stmt = c.q.GetCampaignViewCounts
case "clicks":
stmt = c.q.GetCampaignClickCounts
case "bounces":
stmt = c.q.GetCampaignBounceCounts
default:
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData"))
}
if !strHasLen(fromDate, 10, 30) || !strHasLen(toDate, 10, 30) {
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("analytics.invalidDates"))
}
out := []models.CampaignAnalyticsCount{}
if err := stmt.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
c.log.Printf("error fetching campaign %s: %v", typ, err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
}
return out, nil
}
// GetCampaignAnalyticsLinks returns link click analytics for the given campaign IDs.
func (c *Core) GetCampaignAnalyticsLinks(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsLink, error) {
out := []models.CampaignAnalyticsLink{}
if err := c.q.GetCampaignLinkCounts.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
c.log.Printf("error fetching campaign %s: %v", typ, err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
}
return out, nil
}
// RegisterCampaignView registers a subscriber's view on a campaign.
func (c *Core) RegisterCampaignView(campUUID, subUUID string) error {
if _, err := c.q.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "campaign_id" {
return nil
}
c.log.Printf("error registering campaign view: %s", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return nil
}
// GetLinkURL returns the original URL for a link UUID without recording a click.
func (c *Core) GetLinkURL(linkUUID string) (string, error) {
var url string
if err := c.q.GetLinkURL.Get(&url, linkUUID); err != nil {
c.log.Printf("error getting link URL: %s", err)
return "", echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
}
return url, nil
}
// RegisterCampaignLinkClick registers a subscriber's link click on a campaign.
func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID string) (string, error) {
var url string
if err := c.q.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
return "", echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("public.invalidLink"))
}
c.log.Printf("error registering link click: %s", err)
return "", echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
}
return url, nil
}
// ExportCampaignViews returns an iterator with campaign views for streaming/exporting.
func (c *Core) ExportCampaignViews(since time.Time, batchSize int) func() ([]models.CampaignViewExport, error) {
offset := 0
return func() ([]models.CampaignViewExport, error) {
var out []models.CampaignViewExport
if err := c.q.ExportCampaignViews.Select(&out, since, batchSize, offset); err != nil {
c.log.Printf("error exporting campaign views: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
}
offset += len(out)
return out, nil
}
}
// ExportCampaignLinkClicks returns an iterator with campaign link click for streaming/exporting.
func (c *Core) ExportCampaignLinkClicks(since time.Time, batchSize int) func() ([]models.CampaignClickExport, error) {
offset := 0
return func() ([]models.CampaignClickExport, error) {
var out []models.CampaignClickExport
if err := c.q.ExportCampaignLinkClicks.Select(&out, since, batchSize, offset); err != nil {
c.log.Printf("error exporting campaign link clicks: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
}
offset += len(out)
return out, nil
}
}
// DeleteCampaignViews deletes campaign views older than a given date.
func (c *Core) DeleteCampaignViews(before time.Time) error {
if _, err := c.q.DeleteCampaignViews.Exec(before); err != nil {
c.log.Printf("error deleting campaign views: %s", err)
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
}
return nil
}
// DeleteCampaignLinkClicks deletes campaign views older than a given date.
func (c *Core) DeleteCampaignLinkClicks(before time.Time) error {
if _, err := c.q.DeleteCampaignLinkClicks.Exec(before); err != nil {
c.log.Printf("error deleting campaign link clicks: %s", err)
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
}
return nil
}
+209
View File
@@ -0,0 +1,209 @@
// package core is the collection of re-usable functions that primarily provides data (DB / CRUD) operations
// to the app. For instance, creating and mutating objects like lists, subscribers etc.
// All such methods return an echo.HTTPError{} (which implements error.error) that can be directly returned
// as a response to HTTP handlers without further processing.
package core
import (
"bytes"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"github.com/jmoiron/sqlx"
"source.offmarket.win/aleagle/eaglecast/internal/i18n"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
const (
SortAsc = "asc"
SortDesc = "desc"
matDashboardCharts = "mat_dashboard_charts"
matDashboardCounts = "mat_dashboard_counts"
matListSubStats = "mat_list_subscriber_stats"
)
// Core represents the eaglecast core with all shared, global functions.
type Core struct {
h *Hooks
consts Constants
i18n *i18n.I18n
db *sqlx.DB
q *models.Queries
log *log.Logger
}
// Constants represents constant config.
type Constants struct {
SendOptinConfirmation bool
BounceActions map[string]struct {
Count int
Action string
}
CacheSlowQueries bool
}
// Hooks contains external function hooks that are required by the core package.
type Hooks struct {
SendOptinConfirmation func(models.Subscriber, []int) (int, error)
}
// Opt contains the controllers required to start the core.
type Opt struct {
Constants Constants
I18n *i18n.I18n
DB *sqlx.DB
Queries *models.Queries
Log *log.Logger
}
var (
ErrNotFound = echo.NewHTTPError(http.StatusNotFound, "not found")
)
var (
regexFullTextQuery = regexp.MustCompile(`\s+`)
regexpSpaces = regexp.MustCompile(`[\s]+`)
campQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
subQuerySortFields = []string{"email", "status", "name", "created_at", "updated_at"}
listQuerySortFields = []string{"name", "status", "created_at", "updated_at", "subscriber_count"}
)
// New returns a new instance of the core.
func New(o *Opt, h *Hooks) *Core {
return &Core{
h: h,
consts: o.Constants,
i18n: o.I18n,
db: o.DB,
q: o.Queries,
log: o.Log,
}
}
// RefreshMatViews refreshes all materialized views.
func (c *Core) RefreshMatViews(concurrent bool) error {
for _, v := range []string{matDashboardCharts, matDashboardCounts, matListSubStats} {
_ = c.RefreshMatView(v, true)
}
return nil
}
// RefreshMatView refreshes a Postgres materialized view.
func (c *Core) RefreshMatView(name string, concurrent bool) error {
q := "REFRESH MATERIALIZED VIEW %s %s"
if concurrent {
q = fmt.Sprintf(q, "CONCURRENTLY", name)
} else {
q = fmt.Sprintf(q, "", name)
}
if _, err := c.db.Exec(q); err != nil {
c.log.Printf("error refreshing materialized view: %s: %v", name, err)
return err
}
return nil
}
// refreshCache refreshes a Postgres materialized view if caching is disabled.
func (c *Core) refreshCache(name string, concurrent bool) error {
if c.consts.CacheSlowQueries {
return nil
}
return c.RefreshMatView(name, concurrent)
}
// Given an error, pqErrMsg will try to return pq error details
// if it's a pq error.
func pqErrMsg(err error) string {
if err, ok := err.(*pq.Error); ok {
if err.Detail != "" {
return fmt.Sprintf("%s. %s", err, err.Detail)
}
}
return err.Error()
}
// makeSearchQuery cleans an optional search string and prepares the
// query SQL statement (string interpolated) and returns the
// search query string along with the SQL expression.
func makeSearchQuery(searchStr, orderBy, order, query string, querySortFields []string) (string, string) {
searchStr = makeSearchString(searchStr)
// Sort params.
if !strSliceContains(orderBy, querySortFields) {
orderBy = "created_at"
}
if order != SortAsc && order != SortDesc {
order = SortDesc
}
query = strings.ReplaceAll(query, "%order%", orderBy+" "+order)
return searchStr, query
}
// makeSearchString prepares a search string for use in both tsquery and ILIKE queries.
func makeSearchString(searchStr string) string {
if searchStr == "" {
return ""
}
return `%` + string(regexFullTextQuery.ReplaceAll([]byte(searchStr), []byte("&"))) + `%`
}
// strSliceContains checks if a string is present in the string slice.
func strSliceContains(str string, sl []string) bool {
for _, s := range sl {
if s == str {
return true
}
}
return false
}
// normalizeTags takes a list of string tags and normalizes them by
// lower casing and removing all special characters except for dashes.
func normalizeTags(tags []string) []string {
var (
out []string
dash = []byte("-")
)
for _, t := range tags {
rep := regexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash)
if len(rep) > 0 {
out = append(out, string(rep))
}
}
return out
}
// sanitizeSQLExp does basic sanitisation on arbitrary
// SQL query expressions coming from the frontend.
func sanitizeSQLExp(q string) string {
if len(q) == 0 {
return ""
}
q = strings.TrimSpace(q)
// Remove semicolon suffix.
if q[len(q)-1] == ';' {
q = q[:len(q)-1]
}
return q
}
// strHasLen checks if the given string has a length within min-max.
func strHasLen(str string, min, max int) bool {
return len(str) >= min && len(str) <= max
}
+34
View File
@@ -0,0 +1,34 @@
package core
import (
"net/http"
"github.com/jmoiron/sqlx/types"
"github.com/labstack/echo/v4"
)
// GetDashboardCharts returns chart data points to render on the dashboard.
func (c *Core) GetDashboardCharts() (types.JSONText, error) {
_ = c.refreshCache(matDashboardCharts, false)
var out types.JSONText
if err := c.q.GetDashboardCharts.Get(&out); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err)))
}
return out, nil
}
// GetDashboardCounts returns stats counts to show on the dashboard.
func (c *Core) GetDashboardCounts() (types.JSONText, error) {
_ = c.refreshCache(matDashboardCounts, false)
var out types.JSONText
if err := c.q.GetDashboardCounts.Get(&out); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err)))
}
return out, nil
}
+217
View File
@@ -0,0 +1,217 @@
package core
import (
"net/http"
"github.com/gofrs/uuid/v5"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
type listType struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Type string `json:"type"`
}
// GetLists gets all lists optionally filtered by type and status.
func (c *Core) GetLists(typ, status string, getAll bool, permittedIDs []int) ([]models.List, error) {
out := []models.List{}
if err := c.q.GetLists.Select(&out, typ, status, "id", getAll, pq.Array(permittedIDs)); err != nil {
c.log.Printf("error fetching lists: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
// Replace null tags.
for i, l := range out {
if l.Tags == nil {
out[i].Tags = []string{}
}
// Total counts.
for _, c := range l.SubscriberCounts {
out[i].SubscriberCount += c
}
}
return out, nil
}
// QueryLists gets multiple lists based on multiple query params. Along with the paginated and sliced
// results, the total number of lists in the DB is returned.
func (c *Core) QueryLists(searchStr, typ, optin, status string, tags []string, orderBy, order string, getAll bool, permittedIDs []int, offset, limit int) ([]models.List, int, error) {
_ = c.refreshCache(matListSubStats, false)
if tags == nil {
tags = []string{}
}
var (
out = []models.List{}
queryStr, stmt = makeSearchQuery(searchStr, orderBy, order, c.q.QueryLists, listQuerySortFields)
)
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, status, pq.StringArray(tags), getAll, pq.Array(permittedIDs), offset, limit); err != nil {
c.log.Printf("error fetching lists: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
total := 0
if len(out) > 0 {
total = out[0].Total
// Replace null tags.
for i, l := range out {
if l.Tags == nil {
out[i].Tags = []string{}
}
}
}
return out, total, nil
}
// GetList gets a list by its ID or UUID.
func (c *Core) GetList(id int, uuid string) (models.List, error) {
var uu any
if uuid != "" {
uu = uuid
}
var res []models.List
queryStr, stmt := makeSearchQuery("", "", "", c.q.QueryLists, nil)
if err := c.db.Select(&res, stmt, id, uu, queryStr, "", "", "", pq.StringArray{}, true, nil, 0, 1); err != nil {
c.log.Printf("error fetching lists: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
if len(res) == 0 {
return models.List{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
}
out := res[0]
if out.Tags == nil {
out.Tags = []string{}
}
// Total counts.
for _, c := range out.SubscriberCounts {
out.SubscriberCount += c
}
return out, nil
}
// GetListsByOptin returns lists by optin type.
func (c *Core) GetListsByOptin(ids []int, optinType string) ([]models.List, error) {
out := []models.List{}
if err := c.q.GetListsByOptin.Select(&out, optinType, pq.Array(ids), nil); err != nil {
c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
return out, nil
}
// GetListTypes returns lists by their IDs or UUIDs.
// If ids is given, then the map returned has the list IDs as keys,
// otherwise, they have UUIDs as the keys.
// Note: This is a really weird and awkward API. Ideally, Go Generics
// should've somehow supported generic struct methods.
func (c *Core) GetListTypes(ids []int, uuids []string) (map[any]string, error) {
res := []listType{}
out := map[any]string{}
if err := c.q.GetListTypes.Select(&res, pq.Array(ids), pq.StringArray(uuids)); err != nil {
c.log.Printf("error fetching list types: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
isIDs := ids != nil
for _, r := range res {
if isIDs {
out[r.ID] = r.Type
} else {
out[r.UUID] = r.Type
}
}
return out, nil
}
// CreateList creates a new list.
func (c *Core) CreateList(l models.List) (models.List, error) {
uu, err := uuid.NewV4()
if err != nil {
c.log.Printf("error generating UUID: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
}
if l.Type == "" {
l.Type = models.ListTypePrivate
}
if l.Optin == "" {
l.Optin = models.ListOptinSingle
}
if l.Status == "" {
l.Status = models.ListStatusActive
}
// Insert and read ID.
var newID int
l.UUID = uu.String()
if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description); err != nil {
c.log.Printf("error creating list: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
return c.GetList(newID, "")
}
// UpdateList updates a given list.
func (c *Core) UpdateList(id int, l models.List) (models.List, error) {
res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description)
if err != nil {
c.log.Printf("error updating list: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return models.List{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
}
return c.GetList(id, "")
}
// DeleteList deletes a list.
func (c *Core) DeleteList(id int) error {
return c.DeleteLists([]int{id}, "", true, nil)
}
// DeleteLists deletes multiple lists.
func (c *Core) DeleteLists(ids []int, query string, getAll bool, permittedIDs []int) error {
var queryStr string
if len(ids) > 0 {
queryStr = ""
} else {
queryStr = makeSearchString(query)
}
if _, err := c.q.DeleteLists.Exec(pq.Array(ids), queryStr, getAll, pq.Array(permittedIDs)); err != nil {
c.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
return nil
}
+102
View File
@@ -0,0 +1,102 @@
package core
import (
"database/sql"
"fmt"
"net/http"
"strings"
"github.com/gofrs/uuid/v5"
"source.offmarket.win/aleagle/eaglecast/internal/media"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"gopkg.in/volatiletech/null.v6"
)
// QueryMedia returns media entries optionally filtered by a query string.
func (c *Core) QueryMedia(provider string, s media.Store, query string, offset, limit int) ([]media.Media, int, error) {
out := []media.Media{}
if query != "" {
query = strings.ToLower(query)
}
if err := c.q.QueryMedia.Select(&out, fmt.Sprintf("%%%s%%", query), provider, offset, limit); err != nil {
return out, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.media}", "error", pqErrMsg(err)))
}
total := 0
if len(out) > 0 {
total = out[0].Total
for i := 0; i < len(out); i++ {
out[i].URL = s.GetURL(out[i].Filename)
if out[i].Thumb != "" {
out[i].ThumbURL = null.String{Valid: true, String: s.GetURL(out[i].Thumb)}
}
}
}
return out, total, nil
}
// GetMedia returns a media item.
func (c *Core) GetMedia(id int, uuid, fileName string, s media.Store) (media.Media, error) {
var uu any
if uuid != "" {
uu = uuid
}
var out media.Media
if err := c.q.GetMedia.Get(&out, id, uu, fileName); err != nil {
// If it's ` sql: no rows in result set`, return a 404.
if err == sql.ErrNoRows {
return out, ErrNotFound
}
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
}
out.URL = s.GetURL(out.Filename)
if out.Thumb != "" {
out.ThumbURL = null.String{Valid: true, String: s.GetURL(out.Thumb)}
}
return out, nil
}
// InsertMedia inserts a new media file into the DB.
func (c *Core) InsertMedia(fileName, thumbName, contentType string, meta models.JSON, provider string, s media.Store) (media.Media, error) {
uu, err := uuid.NewV4()
if err != nil {
c.log.Printf("error generating UUID: %v", err)
return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
}
// Write to the DB.
var newID int
if err := c.q.InsertMedia.Get(&newID, uu, fileName, thumbName, contentType, provider, meta); err != nil {
c.log.Printf("error inserting uploaded file to db: %v", err)
return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
}
return c.GetMedia(newID, "", "", s)
}
// DeleteMedia deletes a given media item and returns the filename of the deleted item.
func (c *Core) DeleteMedia(id int) (string, error) {
var fname string
if err := c.q.DeleteMedia.Get(&fname, id); err != nil {
c.log.Printf("error inserting uploaded file to db: %v", err)
return "", echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
}
return fname, nil
}
+180
View File
@@ -0,0 +1,180 @@
package core
import (
"encoding/json"
"net/http"
"source.offmarket.win/aleagle/eaglecast/internal/auth"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
// GetRoles retrieves all roles.
func (c *Core) GetRoles() ([]auth.Role, error) {
out := []auth.Role{}
if err := c.q.GetUserRoles.Select(&out, nil); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
}
return out, nil
}
// GetRole retrieves a role.
func (c *Core) GetRole(id int) (auth.Role, error) {
out := []auth.Role{}
if err := c.q.GetUserRoles.Select(&out, id); err != nil {
return auth.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
}
// Role does not exist.
if len(out) == 0 {
return auth.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", "role not found"))
}
return out[0], nil
}
// GetListRoles retrieves all list roles.
func (c *Core) GetListRoles() ([]auth.ListRole, error) {
out := []auth.ListRole{}
if err := c.q.GetListRoles.Select(&out); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
}
// Unmarshall the nested list permissions, if any.
for n, r := range out {
if r.ListsRaw == nil {
continue
}
if err := json.Unmarshal(r.ListsRaw, &out[n].Lists); err != nil {
c.log.Printf("error unmarshalling list permissions for role %d: %v", r.ID, err)
}
}
return out, nil
}
// CreateRole creates a new role.
func (c *Core) CreateRole(r auth.Role) (auth.Role, error) {
var out auth.Role
if err := c.q.CreateRole.Get(&out, r.Name, auth.RoleTypeUser, pq.Array(r.Permissions)); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return out, nil
}
// CreateListRole creates a new list role.
func (c *Core) CreateListRole(r auth.ListRole) (auth.ListRole, error) {
var out auth.ListRole
if err := c.q.CreateRole.Get(&out, r.Name, auth.RoleTypeList, pq.Array([]string{})); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return out, nil
}
// UpsertListPermissions upserts permission for a role.
func (c *Core) UpsertListPermissions(roleID int, lp []auth.ListPermission) error {
var (
listIDs = make([]int, 0, len(lp))
listPerms = make([][]string, 0, len(lp))
)
for _, p := range lp {
if len(p.Permissions) == 0 {
continue
}
listIDs = append(listIDs, p.ID)
// For the Postgres array unnesting query to work, all permissions arrays should
// have equal number of entries. Add "" in case there's only one of either list:get or list:manage
perms := make([]string, 2)
copy(perms[:], p.Permissions[:])
listPerms = append(listPerms, perms)
}
if _, err := c.q.UpsertListPermissions.Exec(roleID, pq.Array(listIDs), pq.Array(listPerms)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteListPermission deletes a list permission entry from a role.
func (c *Core) DeleteListPermission(roleID, listID int) error {
if _, err := c.q.DeleteListPermission.Exec(roleID, listID); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
}
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
}
return nil
}
// UpdateUserRole updates a given role.
func (c *Core) UpdateUserRole(id int, r auth.Role) (auth.Role, error) {
var out auth.Role
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array(r.Permissions)); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.userRole}", "error", pqErrMsg(err)))
}
if out.ID == 0 {
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.userRole}"))
}
return out, nil
}
// UpdateListRole updates a given role.
func (c *Core) UpdateListRole(id int, r auth.ListRole) (auth.ListRole, error) {
var out auth.ListRole
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array([]string{})); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.listRole}", "error", pqErrMsg(err)))
}
if out.ID == 0 {
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.listRole}"))
}
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.listRole}", "error", pqErrMsg(err)))
}
return out, nil
}
// DeleteRole deletes a given role.
func (c *Core) DeleteRole(id int) error {
if _, err := c.q.DeleteRole.Exec(id); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
}
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package core
import (
"encoding/json"
"net/http"
"github.com/jmoiron/sqlx/types"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
)
// GetSettings returns settings from the DB.
func (c *Core) GetSettings() (models.Settings, error) {
var (
b types.JSONText
out models.Settings
)
if err := c.q.GetSettings.Get(&b); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.settings}", "error", pqErrMsg(err)))
}
// Unmarshal the settings and filter out sensitive fields.
if err := json.Unmarshal([]byte(b), &out); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("settings.errorEncoding", "error", err.Error()))
}
return out, nil
}
// UpdateSettings updates settings.
func (c *Core) UpdateSettings(s models.Settings) error {
// Marshal settings.
b, err := json.Marshal(s)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("settings.errorEncoding", "error", err.Error()))
}
// Update the settings in the DB.
if _, err := c.q.UpdateSettings.Exec(b); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err)))
}
return nil
}
// UpdateSettingsByKey updates a single setting by key.
func (c *Core) UpdateSettingsByKey(key string, value json.RawMessage) error {
if _, err := c.q.UpdateSettingsByKey.Exec(key, value); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err)))
}
return nil
}
+671
View File
@@ -0,0 +1,671 @@
package core
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
"source.offmarket.win/aleagle/eaglecast/internal/auth"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
var (
allowedSubQueryTables = map[string]struct{}{
"subscribers": {},
"lists": {},
"subscriber_lists": {},
"campaigns": {},
"campaign_lists": {},
"campaign_views": {},
"links": {},
"link_clicks": {},
"bounces": {},
}
)
// GetSubscriber fetches a subscriber by one of the given params.
func (c *Core) GetSubscriber(id int, uuid, email string) (models.Subscriber, error) {
var uu any
if uuid != "" {
uu = uuid
}
var out models.Subscribers
if err := c.q.GetSubscriber.Select(&out, id, uu, email); err != nil {
c.log.Printf("error fetching subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name",
fmt.Sprintf("{globals.terms.subscriber} (%d: %s%s)", id, uuid, email)))
}
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
c.log.Printf("error loading subscriber lists: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
return out[0], nil
}
// HasSubscriberLists checks if the given subscribers have at least one of the given lists.
func (c *Core) HasSubscriberLists(subIDs []int, listIDs []int) (map[int]bool, error) {
res := []struct {
SubID int `db:"subscriber_id"`
Has bool `db:"has"`
}{}
if err := c.q.HasSubscriberLists.Select(&res, pq.Array(subIDs), pq.Array(listIDs)); err != nil {
c.log.Printf("error fetching subscriber: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
out := make(map[int]bool, len(res))
for _, r := range res {
out[r.SubID] = r.Has
}
return out, nil
}
// GetSubscribersByEmail fetches a subscriber by one of the given params.
func (c *Core) GetSubscribersByEmail(emails []string) (models.Subscribers, error) {
var out models.Subscribers
if err := c.q.GetSubscribersByEmails.Select(&out, pq.Array(emails)); err != nil {
c.log.Printf("error fetching subscriber: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noKnownSubsToTest"))
}
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
c.log.Printf("error loading subscriber lists: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
return out, nil
}
// QuerySubscribers queries and returns paginated subscrribers based on the given params including the total count.
func (c *Core) QuerySubscribers(searchStr, queryExp string, listIDs []int, subStatus string, order, orderBy string, offset, limit int) (models.Subscribers, int, error) {
// Sort params.
if !strSliceContains(orderBy, subQuerySortFields) {
orderBy = "subscribers.id"
}
if order != SortAsc && order != SortDesc {
order = SortDesc
}
// Required for pq.Array()
if listIDs == nil {
listIDs = []int{}
}
// There's an arbitrary query condition.
cond := "TRUE"
if queryExp != "" {
cond = queryExp
}
// stmt is the raw SQL query.
stmt := strings.ReplaceAll(c.q.QuerySubscribers, "%query%", cond)
stmt = strings.ReplaceAll(stmt, "%order%", orderBy+" "+order)
// Validate the tables used in the query.
if err := validateQueryTables(c.db, stmt, allowedSubQueryTables, pq.Array(listIDs), subStatus, searchStr, offset, limit); err != nil {
c.log.Printf("error validating query tables: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
}
// Create a readonly transaction that just does COUNT() to obtain the count of results
// and to ensure that the arbitrary query is indeed readonly.
total, err := c.getSubscriberCount(searchStr, cond, subStatus, listIDs)
if err != nil {
c.log.Printf("error getting subscriber count: %v", err)
return nil, 0, err
}
// No results.
if total == 0 {
return models.Subscribers{}, 0, nil
}
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
c.log.Printf("error preparing subscriber query: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
defer tx.Rollback()
var out models.Subscribers
if err := tx.Select(&out, stmt, pq.Array(listIDs), subStatus, searchStr, offset, limit); err != nil {
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
// Lazy load lists for each subscriber.
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
c.log.Printf("error fetching subscriber lists: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return out, total, nil
}
// GetSubscriberLists returns a subscriber's lists based on the given conditions.
func (c *Core) GetSubscriberLists(subID int, uuid string, listIDs []int, listUUIDs []string, subStatus string, listType string) ([]models.List, error) {
if listIDs == nil {
listIDs = []int{}
}
if listUUIDs == nil {
listUUIDs = []string{}
}
var uu any
if uuid != "" {
uu = uuid
}
// Fetch double opt-in lists from the given list IDs.
// Get the list of subscription lists where the subscriber hasn't confirmed.
out := []models.List{}
if err := c.q.GetSubscriberLists.Select(&out, subID, uu, pq.Array(listIDs), pq.Array(listUUIDs), subStatus, listType); err != nil {
c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return nil, err
}
return out, nil
}
// GetSubscriberProfileForExport returns the subscriber's profile data as a JSON exportable.
// Get the subscriber's data. A single query that gets the profile, list subscriptions, campaign views,
// and link clicks. Names of private lists are replaced with "Private list".
func (c *Core) GetSubscriberProfileForExport(id int, uuid string) (models.SubscriberExportProfile, error) {
var uu any
if uuid != "" {
uu = uuid
}
var out models.SubscriberExportProfile
if err := c.q.ExportSubscriberData.Get(&out, id, uu); err != nil {
c.log.Printf("error fetching subscriber export data: %v", err)
return models.SubscriberExportProfile{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error()))
}
return out, nil
}
// GetSubscriberActivity returns the subscriber's campaign views and link clicks for the Activity tab.
func (c *Core) GetSubscriberActivity(id int) (models.SubscriberActivity, error) {
var out models.SubscriberActivity
if err := c.q.GetSubscriberActivity.Get(&out, id); err != nil {
c.log.Printf("error fetching subscriber activity: %v", err)
return models.SubscriberActivity{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "activity", "error", err.Error()))
}
return out, nil
}
// ExportSubscribers returns an iterator function that provides lists of subscribers based
// on the given criteria in an exportable form. The iterator function returned can be called
// repeatedly until there are nil subscribers. It's an iterator because exports can be extremely
// large and may have to be fetched in batches from the DB and streamed somewhere.
func (c *Core) ExportSubscribers(searchStr, query string, subIDs, listIDs []int, subStatus string, batchSize int) (func() ([]models.SubscriberExport, error), error) {
if subIDs == nil {
subIDs = []int{}
}
if listIDs == nil {
listIDs = []int{}
}
// There's an arbitrary query condition.
cond := "TRUE"
if query != "" {
cond = query
}
stmt := strings.ReplaceAll(c.q.QuerySubscribersForExport, "%query%", cond)
// Validate the tables used in the query.
if err := validateQueryTables(c.db, stmt, allowedSubQueryTables,
pq.Array(listIDs), 0, pq.Array(subIDs), subStatus, searchStr, batchSize); err != nil {
c.log.Printf("error validating query tables: %v", err)
return nil, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
}
// Create a readonly transaction that just does COUNT() to obtain the count of results
// and to ensure that the arbitrary query is indeed readonly.
if _, err := c.getSubscriberCount(searchStr, cond, subStatus, listIDs); err != nil {
c.log.Printf("error getting subscriber count: %v", err)
return nil, err
}
// Prepare the actual query statement.
tx, err := c.db.Preparex(stmt)
if err != nil {
c.log.Printf("error preparing subscriber query: %v", err)
return nil, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
id := 0
return func() ([]models.SubscriberExport, error) {
var out []models.SubscriberExport
if err := tx.Select(&out, pq.Array(listIDs), id, pq.Array(subIDs), subStatus, searchStr, batchSize); err != nil {
c.log.Printf("error exporting subscribers by query: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return nil, nil
}
id = out[len(out)-1].ID
return out, nil
}, nil
}
// InsertSubscriber inserts a subscriber and returns the ID. The first bool indicates if
// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation.
// bool = optinSent?
func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm, assertOptin bool) (models.Subscriber, bool, error) {
uu, err := uuid.NewV4()
if err != nil {
c.log.Printf("error generating UUID: %v", err)
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
}
sub.UUID = uu.String()
subStatus := models.SubscriptionStatusUnconfirmed
if preconfirm {
subStatus = models.SubscriptionStatusConfirmed
}
if sub.Status == "" {
sub.Status = auth.UserStatusEnabled
}
// For pq.Array()
if listIDs == nil {
listIDs = []int{}
}
if listUUIDs == nil {
listUUIDs = []string{}
}
if err = c.q.InsertSubscriber.Get(&sub.ID,
sub.UUID,
sub.Email,
strings.TrimSpace(sub.Name),
sub.Status,
sub.Attribs,
pq.Array(listIDs),
pq.Array(listUUIDs),
subStatus); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusConflict, c.i18n.T("subscribers.emailExists"))
} else {
// return sub.Subscriber, errSubscriberExists
c.log.Printf("error inserting subscriber: %v", err)
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
}
// Fetch the subscriber's full data. If the subscriber already existed and wasn't
// created, the id will be empty. Fetch the details by e-mail then.
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
if err != nil {
return models.Subscriber{}, false, err
}
hasOptin := false
if !preconfirm && c.consts.SendOptinConfirmation {
// Send a confirmation e-mail (if there are any double opt-in lists).
num, err := c.h.SendOptinConfirmation(out, listIDs)
if assertOptin && err != nil {
return out, hasOptin, err
}
hasOptin = num > 0
}
return out, hasOptin, nil
}
// UpdateSubscriber updates a subscriber's properties.
func (c *Core) UpdateSubscriber(id int, sub models.Subscriber) (models.Subscriber, error) {
// Format raw JSON attributes.
attribs := []byte("{}")
if len(sub.Attribs) > 0 {
if b, err := json.Marshal(sub.Attribs); err != nil {
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscriber}", "error", err.Error()))
} else {
attribs = b
}
}
_, err := c.q.UpdateSubscriber.Exec(id,
sub.Email,
strings.TrimSpace(sub.Name),
sub.Status,
json.RawMessage(attribs),
)
if err != nil {
c.log.Printf("error updating subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
if err != nil {
return models.Subscriber{}, err
}
return out, nil
}
// UpdateSubscriberWithLists updates a subscriber's properties.
// If deleteLists is set to true, all existing subscriptions are deleted and only
// the ones provided are added or retained.
func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm, deleteLists, assertOptin bool, permittedListIDs []int, allowResubscribe bool) (models.Subscriber, bool, error) {
subStatus := models.SubscriptionStatusUnconfirmed
if preconfirm {
subStatus = models.SubscriptionStatusConfirmed
}
// Format raw JSON attributes.
attribs := []byte("{}")
if len(sub.Attribs) > 0 {
if b, err := json.Marshal(sub.Attribs); err != nil {
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscriber}", "error", err.Error()))
} else {
attribs = b
}
}
_, err := c.q.UpdateSubscriberWithLists.Exec(id,
sub.Email,
strings.TrimSpace(sub.Name),
sub.Status,
json.RawMessage(attribs),
pq.Array(listIDs),
pq.Array(listUUIDs),
subStatus,
deleteLists,
pq.Array(permittedListIDs),
allowResubscribe)
if err != nil {
c.log.Printf("error updating subscriber: %v", err)
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
if err != nil {
return models.Subscriber{}, false, err
}
hasOptin := false
if !preconfirm && c.consts.SendOptinConfirmation {
// Send a confirmation e-mail (if there are any double opt-in lists).
num, err := c.h.SendOptinConfirmation(out, listIDs)
if assertOptin && err != nil {
return out, hasOptin, err
}
hasOptin = num > 0
}
return out, hasOptin, nil
}
// BlocklistSubscribers blocklists the given list of subscribers.
func (c *Core) BlocklistSubscribers(subIDs []int) error {
if _, err := c.q.BlocklistSubscribers.Exec(pq.Array(subIDs)); err != nil {
c.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
}
return nil
}
// BlocklistSubscribersByQuery blocklists the given list of subscribers.
func (c *Core) BlocklistSubscribersByQuery(searchStr, queryExp string, listIDs []int, subStatus string) error {
if err := c.q.ExecSubQueryTpl(searchStr, sanitizeSQLExp(queryExp), c.q.BlocklistSubscribersByQuery, listIDs, c.db, subStatus); err != nil {
c.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
}
return nil
}
// DeleteSubscribers deletes the given list of subscribers.
func (c *Core) DeleteSubscribers(subIDs []int, subUUIDs []string) error {
if subIDs == nil {
subIDs = []int{}
}
if subUUIDs == nil {
subUUIDs = []string{}
}
if _, err := c.q.DeleteSubscribers.Exec(pq.Array(subIDs), pq.Array(subUUIDs)); err != nil {
c.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteSubscribersByQuery deletes subscribers by a given arbitrary query expression.
func (c *Core) DeleteSubscribersByQuery(searchStr, queryExp string, listIDs []int, subStatus string) error {
err := c.q.ExecSubQueryTpl(searchStr, sanitizeSQLExp(queryExp), c.q.DeleteSubscribersByQuery, listIDs, c.db, subStatus)
if err != nil {
c.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return err
}
// UnsubscribeByCampaign unsubscribes a given subscriber from lists in a given campaign.
func (c *Core) UnsubscribeByCampaign(subUUID, campUUID string, blocklist bool) error {
if _, err := c.q.UnsubscribeByCampaign.Exec(campUUID, subUUID, blocklist); err != nil {
c.log.Printf("error unsubscribing: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return nil
}
// ConfirmOptionSubscription confirms a subscriber's optin subscription.
func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string, meta models.JSON) error {
if meta == nil {
meta = models.JSON{}
}
if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs), meta); err != nil {
c.log.Printf("error confirming subscription: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteSubscriberBounces deletes the given list of subscribers.
func (c *Core) DeleteSubscriberBounces(id int, uuid string) error {
var uu any
if uuid != "" {
uu = uuid
}
if _, err := c.q.DeleteBouncesBySubscriber.Exec(id, uu); err != nil {
c.log.Printf("error deleting bounces: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.bounces}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteOrphanSubscribers deletes orphan subscriber records (subscribers without lists).
func (c *Core) DeleteOrphanSubscribers() (int, error) {
res, err := c.q.DeleteOrphanSubscribers.Exec()
if err != nil {
c.log.Printf("error deleting orphan subscribers: %v", err)
return 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
n, _ := res.RowsAffected()
return int(n), nil
}
// DeleteBlocklistedSubscribers deletes blocklisted subscribers.
func (c *Core) DeleteBlocklistedSubscribers() (int, error) {
res, err := c.q.DeleteBlocklistedSubscribers.Exec()
if err != nil {
c.log.Printf("error deleting blocklisted subscribers: %v", err)
return 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
n, _ := res.RowsAffected()
return int(n), nil
}
func (c *Core) getSubscriberCount(searchStr, queryExp, subStatus string, listIDs []int) (int, error) {
// If there's no condition, it's a "get all" call which can probably be optionally pulled from cache.
if queryExp == "" {
_ = c.refreshCache(matListSubStats, false)
total := 0
if err := c.q.QuerySubscribersCountAll.Get(&total, pq.Array(listIDs), subStatus); err != nil {
return 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return total, nil
}
// Create a readonly transaction that just does COUNT() to obtain the count of results
// and to ensure that the arbitrary query is indeed readonly.
stmt := strings.ReplaceAll(c.q.QuerySubscribersCount, "%query%", queryExp)
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
c.log.Printf("error preparing subscriber query: %v", err)
return 0, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
defer tx.Rollback()
// Execute the readonly query and get the count of results.
total := 0
if err := tx.Get(&total, stmt, pq.Array(listIDs), subStatus, searchStr); err != nil {
return 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return total, nil
}
// validateQueryTables checks if the query accesses only allowed tables.
func validateQueryTables(db *sqlx.DB, query string, allowedTables map[string]struct{}, args ...any) error {
// Get the EXPLAIN (FORMAT JSON) output.
tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
return err
}
defer tx.Rollback()
var plan string
if err = tx.QueryRow("EXPLAIN (FORMAT JSON) "+query, args...).Scan(&plan); err != nil {
return err
}
// Extract all relation names from the JSON plan.
tables, err := getTablesFromQueryPlan(plan)
if err != nil {
return fmt.Errorf("error getting tables from query: %v", err)
}
// Validate against allowed tables.
for _, table := range tables {
if _, ok := allowedTables[table]; !ok {
return fmt.Errorf("table '%s' is not allowed", table)
}
}
return nil
}
// getTablesFromQueryPlan parses the EXPLAIN JSON to find all "Relation Name" entries.
func getTablesFromQueryPlan(explainJSON string) ([]string, error) {
var plans []map[string]any
if err := json.Unmarshal([]byte(explainJSON), &plans); err != nil {
return nil, err
}
// Collect table names in `tables` recursively.
tables := make(map[string]struct{})
for _, plan := range plans {
traverseQueryPlan(plan, tables)
}
result := make([]string, 0, len(tables))
for table := range tables {
result = append(result, table)
}
return result, nil
}
func traverseQueryPlan(node map[string]any, tables map[string]struct{}) {
if relName, ok := node["Relation Name"].(string); ok {
tables[relName] = struct{}{}
}
// Recursively check nested plans (e.g., subqueries, CTEs).
for _, v := range node {
switch v := v.(type) {
case map[string]any:
traverseQueryPlan(v, tables)
case []any:
for _, item := range v {
if m, ok := item.(map[string]any); ok {
traverseQueryPlan(m, tables)
}
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
package core
import (
"net/http"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
// GetSubscriptions retrieves the subscriptions for a subscriber.
func (c *Core) GetSubscriptions(subID int, subUUID string, allLists bool) ([]models.Subscription, error) {
var out []models.Subscription
err := c.q.GetSubscriptions.Select(&out, subID, subUUID, allLists)
if err != nil {
c.log.Printf("error getting subscriptions: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error()))
}
return out, err
}
// AddSubscriptions adds list subscriptions to subscribers.
func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) error {
if _, err := c.q.AddSubscribersToLists.Exec(pq.Array(subIDs), pq.Array(listIDs), status); err != nil {
c.log.Printf("error adding subscriptions: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
}
return nil
}
// AddSubscriptionsByQuery adds list subscriptions to subscribers by a given arbitrary query expression.
// sourceListIDs is the list of list IDs to filter the subscriber query with.
func (c *Core) AddSubscriptionsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, status string, subStatus string) error {
if sourceListIDs == nil {
sourceListIDs = []int{}
}
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.AddSubscribersToListsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs), status)
if err != nil {
c.log.Printf("error adding subscriptions by query: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteSubscriptions delete list subscriptions from subscribers.
func (c *Core) DeleteSubscriptions(subIDs, listIDs []int) error {
if _, err := c.q.DeleteSubscriptions.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil {
c.log.Printf("error deleting subscriptions: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
}
return nil
}
// DeleteSubscriptionsByQuery deletes list subscriptions from subscribers by a given arbitrary query expression.
// sourceListIDs is the list of list IDs to filter the subscriber query with.
func (c *Core) DeleteSubscriptionsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, subStatus string) error {
if sourceListIDs == nil {
sourceListIDs = []int{}
}
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.DeleteSubscriptionsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs))
if err != nil {
c.log.Printf("error deleting subscriptions by query: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return nil
}
// UnsubscribeLists sets list subscriptions to 'unsubscribed'.
func (c *Core) UnsubscribeLists(subIDs, listIDs []int, listUUIDs []string) error {
if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs), pq.StringArray(listUUIDs)); err != nil {
c.log.Printf("error unsubscribing from lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
}
return nil
}
// UnsubscribeListsByQuery sets list subscriptions to 'unsubscribed' by a given arbitrary query expression.
// sourceListIDs is the list of list IDs to filter the subscriber query with.
func (c *Core) UnsubscribeListsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, subStatus string) error {
if sourceListIDs == nil {
sourceListIDs = []int{}
}
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.UnsubscribeSubscribersFromListsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs))
if err != nil {
c.log.Printf("error unsubscribing from lists by query: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteUnconfirmedSubscriptions sets list subscriptions to 'unsubscribed' by a given arbitrary query expression.
// sourceListIDs is the list of list IDs to filter the subscriber query with.
func (c *Core) DeleteUnconfirmedSubscriptions(beforeDate time.Time) (int, error) {
res, err := c.q.DeleteUnconfirmedSubscriptions.Exec(beforeDate)
if err != nil {
c.log.Printf("error deleting unconfirmed subscribers: %v", err)
return 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
n, _ := res.RowsAffected()
return int(n), nil
}
+88
View File
@@ -0,0 +1,88 @@
package core
import (
"database/sql"
"net/http"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/labstack/echo/v4"
null "gopkg.in/volatiletech/null.v6"
)
// GetTemplates retrieves all templates.
func (c *Core) GetTemplates(status string, noBody bool) ([]models.Template, error) {
out := []models.Template{}
if err := c.q.GetTemplates.Select(&out, 0, noBody, status); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
}
return out, nil
}
// GetTemplate retrieves a given template.
func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
var out []models.Template
if err := c.q.GetTemplates.Select(&out, id, noBody, ""); err != nil {
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return models.Template{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
}
return out[0], nil
}
// CreateTemplate creates a new template.
func (c *Core) CreateTemplate(name, typ, subject string, body []byte, bodySource null.String) (models.Template, error) {
var newID int
if err := c.q.CreateTemplate.Get(&newID, name, typ, subject, body, bodySource); err != nil {
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
return c.GetTemplate(newID, false)
}
// UpdateTemplate updates a given template.
func (c *Core) UpdateTemplate(id int, name, subject string, body []byte, bodySource null.String) (models.Template, error) {
res, err := c.q.UpdateTemplate.Exec(id, name, subject, body, bodySource)
if err != nil {
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return models.Template{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
}
return c.GetTemplate(id, false)
}
// SetDefaultTemplate sets a template as default.
func (c *Core) SetDefaultTemplate(id int) error {
if _, err := c.q.SetDefaultTemplate.Exec(id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteTemplate deletes a given template.
func (c *Core) DeleteTemplate(id int) error {
var delID int
if err := c.q.DeleteTemplate.Get(&delID, id); err != nil && err != sql.ErrNoRows {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
if delID == 0 {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("templates.cantDeleteDefault"))
}
return nil
}
+243
View File
@@ -0,0 +1,243 @@
package core
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"source.offmarket.win/aleagle/eaglecast/internal/auth"
"source.offmarket.win/aleagle/eaglecast/internal/utils"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
"gopkg.in/volatiletech/null.v6"
)
func (c *Core) GetUsers() ([]auth.User, error) {
out := []auth.User{}
if err := c.q.GetUsers.Select(&out); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
}
return c.setupUserFields(out), nil
}
// GetUser retrieves a specific user based on any one given identifier.
func (c *Core) GetUser(id int, username, email string) (auth.User, error) {
var out auth.User
if err := c.q.GetUser.Get(&out, id, username, email); err != nil {
if err == sql.ErrNoRows {
return out, echo.NewHTTPError(http.StatusNotFound,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
}
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
}
return c.setupUserFields([]auth.User{out})[0], nil
}
// CreateUser creates a new user.
func (c *Core) CreateUser(u auth.User) (auth.User, error) {
var (
apiToken string
dbPassword = u.Password
)
// If it's an API user, generate a random token for password
// and set the e-mail to default.
if u.Type == auth.UserTypeAPI {
// Generate a random API token.
tk, err := utils.GenerateRandomString(48)
if err != nil {
return auth.User{}, err
}
apiToken = tk
u.Email = null.String{String: u.Username + "@api", Valid: true}
u.PasswordLogin = false
u.Password = null.String{String: tk, Valid: true}
dbPassword = null.String{String: auth.HashAPIToken(tk), Valid: true}
}
var id int
if err := c.q.CreateUser.Get(&id, u.Username, u.PasswordLogin, dbPassword, u.Email, u.Name, u.Type, u.UserRoleID, u.ListRoleID, u.Status); err != nil {
return auth.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
// Hide the password field in the response except for when the user type is an API token,
// where the frontend shows the token on the UI just once.
if u.Type != auth.UserTypeAPI {
u.Password = null.String{Valid: false}
}
out, err := c.GetUser(id, "", "")
if u.Type == auth.UserTypeAPI {
out.Password = null.String{String: apiToken, Valid: true}
}
return out, err
}
// UpdateUser updates a given user.
func (c *Core) UpdateUser(id int, u auth.User) (auth.User, error) {
listRoleID := 0
if u.ListRoleID == nil {
listRoleID = -1
} else {
listRoleID = *u.ListRoleID
}
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.UserRoleID, listRoleID, u.Status)
if err != nil {
return auth.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return auth.User{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
}
out, err := c.GetUser(id, "", "")
return out, err
}
// UpdateUserProfile updates the basic fields of a given uesr (name, email, password).
func (c *Core) UpdateUserProfile(id int, u auth.User) (auth.User, error) {
res, err := c.q.UpdateUserProfile.Exec(id, u.Name, u.Email, u.PasswordLogin, u.Password)
if err != nil {
return auth.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return auth.User{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
}
return c.GetUser(id, "", "")
}
// UpdateUserLogin updates a user's record post-login.
func (c *Core) UpdateUserLogin(id int, avatar string) error {
if _, err := c.q.UpdateUserLogin.Exec(id, avatar); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
return nil
}
// SetTwoFA sets or clears the 2FA configuration for a user.
func (c *Core) SetTwoFA(id int, twofaType, twofaKey string) error {
if _, err := c.q.SetUserTwoFA.Exec(id, twofaType, twofaKey); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteUserSessions deletes all sessions for a given user ID, optionally
// excluding a specific session ID (to keep the current session alive).
func (c *Core) DeleteUserSessions(userID int, excludeID string) error {
if _, err := c.q.DeleteUserSessions.Exec(strconv.Itoa(userID), excludeID); err != nil {
return err
}
return nil
}
// DeleteUsers deletes a given user.
func (c *Core) DeleteUsers(ids []int) error {
res, err := c.q.DeleteUsers.Exec(pq.Array(ids))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
if num, err := res.RowsAffected(); err != nil || num == 0 {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
}
return nil
}
// LoginUser attempts to log the given user_id in by matching the password.
func (c *Core) LoginUser(username, password string) (auth.User, error) {
var out auth.User
if err := c.q.LoginUser.Get(&out, username, password); err != nil {
if err == sql.ErrNoRows {
return out, echo.NewHTTPError(http.StatusForbidden, c.i18n.T("users.invalidLogin"))
}
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
}
return out, nil
}
// setupUserFields prepares and sets up various user fields.
func (c *Core) setupUserFields(users []auth.User) []auth.User {
for n, u := range users {
u := u
if u.Password.String != "" {
u.HasPassword = true
u.PasswordLogin = true
}
if u.Type == auth.UserTypeAPI {
u.Email = null.String{}
}
u.UserRole.ID = u.UserRoleID
u.UserRole.Name = u.UserRoleName
u.UserRole.Permissions = u.UserRolePerms
u.UserRoleID = 0
// Prepare lookup maps.
u.ListPermissionsMap = make(map[int]map[string]struct{})
u.PermissionsMap = make(map[string]struct{})
for _, p := range u.UserRolePerms {
u.PermissionsMap[p] = struct{}{}
}
if u.ListRoleID != nil {
// Unmarshall the raw list perms map.
var listPerms []auth.ListPermission
if u.ListsPermsRaw != nil {
if err := json.Unmarshal(*u.ListsPermsRaw, &listPerms); err != nil {
c.log.Printf("error unmarshalling list permissions for role %d: %v", u.ID, err)
}
}
u.ListRole = &auth.ListRolePermissions{ID: *u.ListRoleID, Name: u.ListRoleName.String, Lists: listPerms}
// Iterate each list in the list permissions and setup get/manage list IDs.
for _, p := range listPerms {
u.ListPermissionsMap[p.ID] = make(map[string]struct{})
for _, perm := range p.Permissions {
u.ListPermissionsMap[p.ID][perm] = struct{}{}
// List IDs with get / manage permissions.
if perm == auth.PermListGet {
u.GetListIDs = append(u.GetListIDs, p.ID)
}
if perm == auth.PermListManage {
u.ManageListIDs = append(u.ManageListIDs, p.ID)
}
}
}
}
users[n] = u
}
return users
}
+100
View File
@@ -0,0 +1,100 @@
// Package events implements a simple event broadcasting mechanism
// for usage in broadcasting error messages, postbacks etc. various
// channels.
package events
import (
"bytes"
"fmt"
"io"
"sync"
)
const (
TypeError = "error"
)
// Event represents a single event in the system.
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Message string `json:"message"`
Data any `json:"data"`
Channels []string `json:"-"`
}
type Events struct {
subs map[string]chan Event
sync.RWMutex
}
// New returns a new instance of Events.
func New() *Events {
return &Events{
subs: make(map[string]chan Event),
}
}
// Subscribe returns a channel to which the given event `types` are streamed.
// id is the unique identifier for the caller. A caller can only register
// for subscription once.
func (ev *Events) Subscribe(id string) (chan Event, error) {
ev.Lock()
defer ev.Unlock()
if ch, ok := ev.subs[id]; ok {
return ch, nil
}
ch := make(chan Event, 100)
ev.subs[id] = ch
return ch, nil
}
// Unsubscribe unsubscribes a subscriber (obviously).
func (ev *Events) Unsubscribe(id string) {
ev.Lock()
defer ev.Unlock()
delete(ev.subs, id)
}
// Publish publishes an event to all subscribers.
func (ev *Events) Publish(e Event) error {
ev.Lock()
defer ev.Unlock()
for _, ch := range ev.subs {
select {
case ch <- e:
default:
return fmt.Errorf("event queue full for type: %s", e.Type)
}
}
return nil
}
// This implements an io.Writer specifically for receiving error messages
// mirrored (io.MultiWriter) from error log writing.
type wri struct {
ev *Events
}
func (w *wri) Write(b []byte) (n int, err error) {
// Only broadcast error messages.
if !bytes.Contains(b, []byte("error")) {
return 0, nil
}
w.ev.Publish(Event{
Type: TypeError,
Message: string(b),
})
return len(b), nil
}
func (ev *Events) ErrWriter() io.Writer {
return &wri{ev: ev}
}
+164
View File
@@ -0,0 +1,164 @@
// i18n is a simple package that translates strings using a language map.
// It mimics some functionality of the vue-i18n library so that the same JSON
// language map may be used in the JS frontend and the Go backend.
package i18n
import (
"encoding/json"
"errors"
"regexp"
"strings"
)
// I18n offers translation functions over a language map.
type I18n struct {
Code string `json:"code"`
Name string `json:"name"`
langMap map[string]string
}
var reParam = regexp.MustCompile(`(?i)\{([a-z0-9-.]+)\}`)
// New returns an I18n instance.
func New(b []byte) (*I18n, error) {
var l map[string]string
if err := json.Unmarshal(b, &l); err != nil {
return nil, err
}
code, ok := l["_.code"]
if !ok {
return nil, errors.New("missing _.code field in language file")
}
name, ok := l["_.name"]
if !ok {
return nil, errors.New("missing _.name field in language file")
}
return &I18n{
langMap: l,
Code: code,
Name: name,
}, nil
}
// Load loads a JSON language map into the instance overwriting
// existing keys that conflict.
func (i *I18n) Load(b []byte) error {
var l map[string]string
if err := json.Unmarshal(b, &l); err != nil {
return err
}
for k, v := range l {
i.langMap[k] = v
}
return nil
}
// JSON returns the languagemap as raw JSON.
func (i *I18n) JSON() []byte {
b, _ := json.Marshal(i.langMap)
return b
}
// T returns the translation for the given key similar to vue i18n's t().
func (i *I18n) T(key string) string {
s, ok := i.langMap[key]
if !ok {
return key
}
return i.getSingular(s)
}
// Ts returns the translation for the given key similar to vue i18n's t()
// and substitutes the params in the given map in the translated value.
// In the language values, the substitutions are represented as: {key}
// The params and values are received as a pairs of succeeding strings.
// That is, the number of these arguments should be an even number.
// eg: Ts("globals.message.notFound",
//
// "name", "campaigns",
// "error", err)
func (i *I18n) Ts(key string, params ...string) string {
if len(params)%2 != 0 {
return key + `: Invalid arguments`
}
s, ok := i.langMap[key]
if !ok {
return key
}
s = i.getSingular(s)
for n := 0; n < len(params); n += 2 {
// If there are {params} in the param values, substitute them.
val := i.subAllParams(params[n+1])
s = strings.ReplaceAll(s, `{`+params[n]+`}`, val)
}
return s
}
// Tc returns the translation for the given key similar to vue i18n's tc().
// It expects the language string in the map to be of the form `Singular | Plural` and
// returns `Plural` if n > 1, or `Singular` otherwise.
func (i *I18n) Tc(key string, n int) string {
s, ok := i.langMap[key]
if !ok {
return key
}
// Plural.
if n > 1 {
return i.getPlural(s)
}
return i.getSingular(s)
}
// getSingular returns the singular term from the vuei18n pipe separated value.
// singular term | plural term
func (i *I18n) getSingular(s string) string {
if !strings.Contains(s, "|") {
return s
}
return strings.TrimSpace(strings.Split(s, "|")[0])
}
// getPlural returns the plural term from the vuei18n pipe separated value.
// singular term | plural term
func (i *I18n) getPlural(s string) string {
if !strings.Contains(s, "|") {
return s
}
chunks := strings.Split(s, "|")
if len(chunks) == 2 {
return strings.TrimSpace(chunks[1])
}
return strings.TrimSpace(chunks[0])
}
// subAllParams recursively resolves and replaces all {params} in a string.
func (i *I18n) subAllParams(s string) string {
if !strings.Contains(s, `{`) {
return s
}
parts := reParam.FindAllStringSubmatch(s, -1)
if len(parts) < 1 {
return s
}
for _, p := range parts {
s = strings.ReplaceAll(s, p[0], i.T(p[1]))
}
return i.subAllParams(s)
}
+827
View File
@@ -0,0 +1,827 @@
package manager
import (
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"html/template"
"log"
"net/textproto"
"net/url"
"path"
"regexp"
"strings"
"sync"
"time"
"maps"
"github.com/Masterminds/sprig/v3"
"source.offmarket.win/aleagle/eaglecast/internal/i18n"
"source.offmarket.win/aleagle/eaglecast/internal/notifs"
"source.offmarket.win/aleagle/eaglecast/models"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// attribInlineEmbed is the HTML attrib used to mark <img> tags whose source
// should be embedded as a multipart cid inline attachment.
const attribInlineEmbed = "data-embed"
var (
reInlineImage = regexp.MustCompile(`(?is)<img\b[^>]*\b` + attribInlineEmbed + `\b[^>]*>`)
reImgSrc = regexp.MustCompile(`(?is)(\s)src\s*=\s*(?:"([^"]*)"|'([^']*)')`)
)
const (
// BaseTPL is the name of the base template.
BaseTPL = "base"
// ContentTpl is the name of the compiled message.
ContentTpl = "content"
dummyUUID = "00000000-0000-0000-0000-000000000000"
)
// Store represents a data backend, such as a database,
// that provides subscriber and campaign records.
type Store interface {
NextCampaigns(currentIDs []int64, sentCounts []int64) ([]*models.Campaign, error)
NextSubscribers(campID, limit int) ([]models.Subscriber, error)
GetCampaign(campID int) (*models.Campaign, error)
GetAttachment(mediaID int) (models.Attachment, error)
GetInlineAttachmentByFilename(filename string) (models.Attachment, string, error)
UpdateCampaignStatus(campID int, status string) error
UpdateCampaignCounts(campID int, toSend int, sent int, lastSubID int) error
CreateLink(url string) (string, error)
BlocklistSubscriber(id int64) error
DeleteSubscriber(id int64) error
}
// Messenger is an interface for a generic messaging backend,
// for instance, e-mail, SMS etc.
type Messenger interface {
Name() string
Push(models.Message) error
Flush() error
Close() error
}
// CampStats contains campaign stats like per minute send rate.
type CampStats struct {
SendRate int
}
// Manager handles the scheduling, processing, and queuing of campaigns
// and message pushes.
type Manager struct {
cfg Config
store Store
i18n *i18n.I18n
messengers map[string]Messenger
fnNotify func(subject string, data any) error
log *log.Logger
// Campaigns that are currently running.
pipes map[int]*pipe
pipesMut sync.RWMutex
tpls map[int]*models.Template
tplsMut sync.RWMutex
// Links generated using Track() are cached here so as to not query
// the database for the link UUID for every message sent. This has to
// be locked as it may be used externally when previewing campaigns.
links map[string]string
linksMut sync.RWMutex
nextPipes chan *pipe
campMsgQ chan CampaignMessage
msgQ chan models.Message
// Sliding window keeps track of the total number of messages sent in a period
// and on reaching the specified limit, waits until the window is over before
// sending further messages.
slidingCount int
slidingStart time.Time
tplFuncs template.FuncMap
}
// CampaignMessage represents an instance of campaign message to be pushed out,
// specific to a subscriber, via the campaign's messenger.
type CampaignMessage struct {
Campaign *models.Campaign
Subscriber models.Subscriber
from string
to string
subject string
body []byte
altBody []byte
unsubURL string
headers models.Headers
pipe *pipe
}
// Config has parameters for configuring the manager.
type Config struct {
// Number of subscribers to pull from the DB in a single iteration.
BatchSize int
Concurrency int
MessageRate int
MaxSendErrors int
SlidingWindow bool
SlidingWindowDuration time.Duration
SlidingWindowRate int
RequeueOnError bool
FromEmail string
IndividualTracking bool
DisableTracking bool
LinkTrackURL string
UnsubURL string
OptinURL string
MessageURL string
ViewTrackURL string
ArchiveURL string
RootURL string
UnsubHeader bool
// Interval to scan the DB for active campaign checkpoints.
ScanInterval time.Duration
// ScanCampaigns indicates whether this instance of manager will scan the DB
// for active campaigns and process them.
// This can be used to run multiple instances of eaglecast
// (exposed to the internet, private etc.) where only one does campaign
// processing while the others handle other kinds of traffic.
ScanCampaigns bool
}
var pushTimeout = time.Second * 3
// New returns a new instance of Mailer.
func New(cfg Config, store Store, i *i18n.I18n, l *log.Logger) *Manager {
if cfg.BatchSize < 1 {
cfg.BatchSize = 1000
}
if cfg.Concurrency < 1 {
cfg.Concurrency = 1
}
if cfg.MessageRate < 1 {
cfg.MessageRate = 1
}
m := &Manager{
cfg: cfg,
store: store,
i18n: i,
fnNotify: func(subject string, data any) error {
return notifs.NotifySystem(subject, notifs.TplCampaignStatus, data, nil)
},
log: l,
messengers: make(map[string]Messenger),
pipes: make(map[int]*pipe),
tpls: make(map[int]*models.Template),
links: make(map[string]string),
nextPipes: make(chan *pipe, 1000),
campMsgQ: make(chan CampaignMessage, cfg.Concurrency*cfg.MessageRate*2),
msgQ: make(chan models.Message, cfg.Concurrency*cfg.MessageRate*2),
slidingStart: time.Now(),
}
m.tplFuncs = m.makeGnericFuncMap()
return m
}
// AddMessenger adds a Messenger messaging backend to the manager.
func (m *Manager) AddMessenger(msg Messenger) error {
id := msg.Name()
if _, ok := m.messengers[id]; ok {
return fmt.Errorf("messenger '%s' is already loaded", id)
}
m.messengers[id] = msg
return nil
}
// PushMessage pushes an arbitrary non-campaign Message to be sent out by the workers.
// It times out if the queue is busy.
func (m *Manager) PushMessage(msg models.Message) error {
t := time.NewTicker(pushTimeout)
defer t.Stop()
select {
case m.msgQ <- msg:
case <-t.C:
m.log.Printf("message push timed out: '%s'", msg.Subject)
return errors.New("message push timed out")
}
return nil
}
// PushCampaignMessage pushes a campaign messages into a queue to be sent out by the workers.
// It times out if the queue is busy.
func (m *Manager) PushCampaignMessage(msg CampaignMessage) error {
t := time.NewTicker(pushTimeout)
defer t.Stop()
// Load any media/attachments.
if err := m.attachMedia(msg.Campaign); err != nil {
return err
}
select {
case m.campMsgQ <- msg:
case <-t.C:
m.log.Printf("message push timed out: '%s'", msg.Subject())
return errors.New("message push timed out")
}
return nil
}
// HasMessenger checks if a given messenger is registered.
func (m *Manager) HasMessenger(id string) bool {
_, ok := m.messengers[id]
return ok
}
// HasRunningCampaigns checks if there are any active campaigns.
func (m *Manager) HasRunningCampaigns() bool {
m.pipesMut.Lock()
defer m.pipesMut.Unlock()
return len(m.pipes) > 0
}
// GetCampaignStats returns campaign statistics.
func (m *Manager) GetCampaignStats(id int) CampStats {
n := 0
m.pipesMut.Lock()
if c, ok := m.pipes[id]; ok {
n = int(c.rate.Rate())
}
m.pipesMut.Unlock()
return CampStats{SendRate: n}
}
// Run is a blocking function (that should be invoked as a goroutine)
// that scans the data source at regular intervals for pending campaigns,
// and queues them for processing. The process queue fetches batches of
// subscribers and pushes messages to them for each queued campaign
// until all subscribers are exhausted, at which point, a campaign is marked
// as "finished".
func (m *Manager) Run() {
if m.cfg.ScanCampaigns {
// Periodically scan campaigns and push running campaigns to nextPipes
// to fetch subscribers from the campaign.
go m.scanCampaigns(m.cfg.ScanInterval)
}
// Spawn N message workers.
for i := 0; i < m.cfg.Concurrency; i++ {
go m.worker()
}
// Indefinitely wait on the pipe queue to fetch the next set of subscribers
// for any active campaigns.
for p := range m.nextPipes {
has, err := p.NextSubscribers()
if err != nil {
m.log.Printf("error processing campaign batch (%s): %v", p.camp.Name, err)
// If the batch fails, stop the pipe and release it so that it doesn't hang forever.
// The cleanup() records the state in DB and scanCampaigns() picks it up at a later point.
p.Stop(false)
p.wg.Done()
continue
}
if has {
// There are more subscribers to fetch. Queue again.
select {
case m.nextPipes <- p:
default:
// If the queue is full for any reason, stop the pipe and release it.
// The cleanup() records the state in DB and scanCampaigns() picks it up
// at a later point.
p.Stop(false)
p.wg.Done()
}
} else {
// The pipe is created with a +1 on the waitgroup pseudo counter
// so that it immediately waits. Subsequently, every message created
// is incremented in the counter in pipe.newMessage(), and when it's'
// processed (or ignored when a campaign is paused or cancelled),
// the count is's reduced in worker().
//
// This marks down the original non-message +1, causing the waitgroup
// to be released and the pipe to end, triggering the pg.Wait()
// in newPipe() that calls pipe.cleanup().
p.wg.Done()
}
}
}
// CacheTpl caches a template for ad-hoc use. This is currently only used by tx templates.
func (m *Manager) CacheTpl(id int, tpl *models.Template) {
if body, atts := m.ApplyInlineImages(tpl.Body); len(atts) > 0 {
tpl.Body = body
tpl.Attachments = atts
if err := tpl.Compile(m.GenericTemplateFuncs()); err != nil {
m.log.Printf("error recompiling tx template %d after inline image: %v", id, err)
return
}
}
m.tplsMut.Lock()
m.tpls[id] = tpl
m.tplsMut.Unlock()
}
// DeleteTpl deletes a cached template.
func (m *Manager) DeleteTpl(id int) {
m.tplsMut.Lock()
delete(m.tpls, id)
m.tplsMut.Unlock()
}
// GetTpl returns a cached template.
func (m *Manager) GetTpl(id int) (*models.Template, error) {
m.tplsMut.RLock()
tpl, ok := m.tpls[id]
m.tplsMut.RUnlock()
if !ok {
return nil, fmt.Errorf("template %d not found", id)
}
return tpl, nil
}
// TemplateFuncs returns the template functions to be applied into
// compiled campaign templates.
func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
f := template.FuncMap{
"TrackLink": func(url string, msg *CampaignMessage) string {
if m.cfg.DisableTracking {
return url
}
subUUID := msg.Subscriber.UUID
if !m.cfg.IndividualTracking {
subUUID = dummyUUID
}
return m.trackLink(url, msg.Campaign.UUID, subUUID)
},
"TrackView": func(msg *CampaignMessage) template.HTML {
if m.cfg.DisableTracking {
return template.HTML("")
}
subUUID := msg.Subscriber.UUID
if !m.cfg.IndividualTracking {
subUUID = dummyUUID
}
return template.HTML(fmt.Sprintf(`<img src="%s" alt="" />`,
fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, subUUID)))
},
"UnsubscribeURL": func(msg *CampaignMessage) string {
return msg.unsubURL
},
"ManageURL": func(msg *CampaignMessage) string {
return msg.unsubURL + "?manage=true"
},
"OptinURL": func(msg *CampaignMessage) string {
// Add list IDs.
// TODO: Show private lists list on optin e-mail
return fmt.Sprintf(m.cfg.OptinURL, msg.Subscriber.UUID, "")
},
"MessageURL": func(msg *CampaignMessage) string {
return fmt.Sprintf(m.cfg.MessageURL, c.UUID, msg.Subscriber.UUID)
},
"ArchiveURL": func() string {
return m.cfg.ArchiveURL
},
"RootURL": func() string {
return m.cfg.RootURL
},
}
maps.Copy(f, m.tplFuncs)
return f
}
func (m *Manager) GenericTemplateFuncs() template.FuncMap {
return m.tplFuncs
}
// StopCampaign marks a running campaign as stopped so that all its queued messages are ignored.
func (m *Manager) StopCampaign(id int) {
m.pipesMut.RLock()
if p, ok := m.pipes[id]; ok {
p.Stop(false)
}
m.pipesMut.RUnlock()
}
// Close closes and exits the campaign manager.
func (m *Manager) Close() {
close(m.nextPipes)
close(m.msgQ)
}
// scanCampaigns is a blocking function that periodically scans the data source
// for campaigns to process and dispatches them to the manager. It feeds campaigns
// into nextPipes.
func (m *Manager) scanCampaigns(tick time.Duration) {
t := time.NewTicker(tick)
defer t.Stop()
// Periodically scan the data source for campaigns to process.
for range t.C {
ids, counts := m.getCurrentCampaigns()
campaigns, err := m.store.NextCampaigns(ids, counts)
if err != nil {
m.log.Printf("error fetching campaigns: %v", err)
continue
}
for _, c := range campaigns {
// Create a new pipe that'll handle this campaign's states.
p, err := m.newPipe(c)
if err != nil {
m.log.Printf("error processing campaign (%s): %v", c.Name, err)
continue
}
m.log.Printf("start processing campaign (%s)", c.Name)
// If subscriber processing is busy, move on. Blocking and waiting
// can end up in a race condition where the waiting campaign's
// state in the data source has changed.
select {
case m.nextPipes <- p:
default:
// If the queue is full for any reason, stop the pipe and release it.
// The cleanup() records the state in DB and scanCampaigns() picks it up
// at a later point.
p.Stop(false)
p.wg.Done()
}
}
}
}
// worker is a blocking function that perpetually listents to events (message) on different
// queues and processes them.
func (m *Manager) worker() {
// Counter to keep track of the message / sec rate limit.
numMsg := 0
for {
select {
// Campaign message.
case msg, ok := <-m.campMsgQ:
if !ok {
return
}
// If the campaign has ended or stopped, ignore the message.
if msg.pipe != nil && msg.pipe.stopped.Load() {
// Reduce the message counter on the pipe.
msg.pipe.wg.Done()
continue
}
// Pause on hitting the message rate.
if numMsg >= m.cfg.MessageRate {
time.Sleep(time.Second)
numMsg = 0
}
numMsg++
// Outgoing message.
out := models.Message{
From: msg.from,
To: []string{msg.to},
Subject: msg.subject,
ContentType: msg.Campaign.ContentType,
Body: msg.body,
AltBody: msg.altBody,
Subscriber: msg.Subscriber,
Campaign: msg.Campaign,
Attachments: msg.Campaign.Attachments,
}
h := textproto.MIMEHeader{}
h.Set(models.EmailHeaderCampaignUUID, msg.Campaign.UUID)
h.Set(models.EmailHeaderSubscriberUUID, msg.Subscriber.UUID)
// Attach List-Unsubscribe headers?
if m.cfg.UnsubHeader {
h.Set("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
h.Set("List-Unsubscribe", `<`+msg.unsubURL+`>`)
}
// Attach any custom headers.
for _, set := range msg.headers {
for hdr, val := range set {
h.Add(hdr, val)
}
}
// Set the headers.
out.Headers = h
// Push the message to the messenger.
err := m.messengers[msg.Campaign.Messenger].Push(out)
if err != nil {
m.log.Printf("error sending message in campaign %s: subscriber %d: %v", msg.Campaign.Name, msg.Subscriber.ID, err)
}
// Increment the send rate or the error counter if there was an error.
if msg.pipe != nil {
// Mark the message as done.
msg.pipe.wg.Done()
if err != nil {
// Call the error callback, which keeps track of the error count
// and stops the campaign if the error count exceeds the threshold.
msg.pipe.OnError()
} else {
id := uint64(msg.Subscriber.ID)
if id > msg.pipe.lastID.Load() {
msg.pipe.lastID.Store(uint64(msg.Subscriber.ID))
}
msg.pipe.rate.Incr(1)
msg.pipe.sent.Add(1)
}
}
// Arbitrary message.
case msg, ok := <-m.msgQ:
if !ok {
return
}
// Push the message to the messenger.
if err := m.messengers[msg.Messenger].Push(msg); err != nil {
m.log.Printf("error sending message '%s': %v", msg.Subject, err)
}
}
}
}
// getCurrentCampaigns returns the IDs of campaigns currently being processed
// and their sent counts.
func (m *Manager) getCurrentCampaigns() ([]int64, []int64) {
// Needs to return an empty slice in case there are no campaigns.
m.pipesMut.RLock()
defer m.pipesMut.RUnlock()
var (
ids = make([]int64, 0, len(m.pipes))
counts = make([]int64, 0, len(m.pipes))
)
for _, p := range m.pipes {
ids = append(ids, int64(p.camp.ID))
// Get the sent counts for campaigns and reset them to 0
// as in the database, they're stored cumulatively (sent += $newSent).
counts = append(counts, p.sent.Load())
p.sent.Store(0)
}
return ids, counts
}
// trackLink register a URL and return its UUID to be used in message templates
// for tracking links.
func (m *Manager) trackLink(url, campUUID, subUUID string) string {
if m.cfg.DisableTracking {
return url
}
url = strings.ReplaceAll(url, "&amp;", "&")
m.linksMut.RLock()
if uu, ok := m.links[url]; ok {
m.linksMut.RUnlock()
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
}
m.linksMut.RUnlock()
// Register link.
uu, err := m.store.CreateLink(url)
if err != nil {
m.log.Printf("error registering tracking for link '%s': %v", url, err)
// If the registration fails, fail over to the original URL.
return url
}
m.linksMut.Lock()
m.links[url] = uu
m.linksMut.Unlock()
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
}
// sendNotif sends a notification to registered admin e-mails.
func (m *Manager) sendNotif(c *models.Campaign, status, reason string) error {
var (
subject = fmt.Sprintf("%s: %s", cases.Title(language.Und).String(status), c.Name)
data = map[string]any{
"ID": c.ID,
"Name": c.Name,
"Status": status,
"Sent": c.Sent,
"ToSend": c.ToSend,
"Reason": reason,
}
)
return m.fnNotify(subject, data)
}
// makeGnericFuncMap returns a generic template func map with custom template
// functions and sprig template functions.
func (m *Manager) makeGnericFuncMap() template.FuncMap {
funcs := template.FuncMap{
"Date": func(layout string) string {
if layout == "" {
layout = time.ANSIC
}
return time.Now().Format(layout)
},
"L": func() *i18n.I18n {
return m.i18n
},
"Safe": func(safeHTML string) template.HTML {
return template.HTML(safeHTML)
},
}
// Copy spring functions.
sprigFuncs := sprig.GenericFuncMap()
delete(sprigFuncs, "env")
delete(sprigFuncs, "expandenv")
delete(sprigFuncs, "getHostByName")
maps.Copy(funcs, sprigFuncs)
return funcs
}
// attachMedia loads any media/attachments from the media store and attaches
// the byte blobs to the campaign. Inline attachments are skipped as they're
// loaded earlier by LoadInlineImages().
func (m *Manager) attachMedia(c *models.Campaign) error {
// Already loaded if any non-inline attachment is present.
for _, a := range c.Attachments {
if !a.IsInline {
return nil
}
}
for _, mid := range []int64(c.MediaIDs) {
a, err := m.store.GetAttachment(int(mid))
if err != nil {
return fmt.Errorf("error fetching attachment %d on campaign %s: %v", mid, c.Name, err)
}
c.Attachments = append(c.Attachments, a)
}
return nil
}
// LoadInlineImages resolves any <img ... data-embed ...> tags in the campaign
// body and template body one time before CompileTemplate.
func (m *Manager) LoadInlineImages(c *models.Campaign) error {
if c.ContentType == models.CampaignContentTypePlain {
return nil
}
cidCache := make(map[string]string)
body, atts := m.applyInlineImages(c.Body, cidCache)
c.Body = body
tplBody, tplAtts := m.applyInlineImages(c.TemplateBody, cidCache)
c.TemplateBody = tplBody
atts = append(atts, tplAtts...)
c.Attachments = append(c.Attachments, atts...)
return nil
}
// ApplyInlineImages scans body for <img ... data-embed ...> tags, resolves
// each unique src filename to a media item, attaches it as an inline part, and
// rewrites the matched img src to cid.
func (m *Manager) ApplyInlineImages(body string) (string, []models.Attachment) {
return m.applyInlineImages(body, make(map[string]string))
}
func (m *Manager) applyInlineImages(body string, cache map[string]string) (string, []models.Attachment) {
if !strings.Contains(body, attribInlineEmbed) {
return body, nil
}
var atts []models.Attachment
out := reInlineImage.ReplaceAllStringFunc(body, func(tag string) string {
src := extractSrc(tag)
if src == "" || strings.HasPrefix(strings.ToLower(src), "cid:") {
return tag
}
fname := filenameFromSrc(src)
if fname == "" {
return tag
}
cid, ok := cache[src]
if !ok {
if a, c, err := m.store.GetInlineAttachmentByFilename(fname); err == nil {
atts = append(atts, a)
cid = c
} else {
m.log.Printf("inline image %q not embedded: %v", src, err)
}
cache[src] = cid
}
if cid == "" {
return tag
}
return reImgSrc.ReplaceAllString(tag, `${1}src="cid:`+cid+`"`)
})
return out, atts
}
// MakeContentID returns a standard `Content-ID` value (without the angle brackets).
func MakeContentID(key string) string {
sum := sha1.Sum([]byte(key))
return hex.EncodeToString(sum[:8]) + "@email"
}
// filenameFromSrc extracts a media filename from an <img src> value (ignoring
// any URLs and just capturing the filename.
func filenameFromSrc(src string) string {
if src == "" {
return ""
}
p := src
if u, err := url.Parse(src); err == nil && u.Path != "" {
p = u.Path
}
return path.Base(p)
}
// MakeAttachmentHeader returns a textproto.MIMEHeader for an email
// attachment. Default encoding is base64 and contentType is application/octet-stream.
func MakeAttachmentHeader(filename, encoding, contentType string) textproto.MIMEHeader {
return makeAttachmentHeader("attachment", filename, encoding, contentType, "")
}
// MakeInlineAttachmentHeader returns a textproto.MIMEHeader for an inline
// image attachment referenced from the HTML body via a cid URL.
func MakeInlineAttachmentHeader(filename, encoding, contentType, contentID string) textproto.MIMEHeader {
return makeAttachmentHeader("inline", filename, encoding, contentType, contentID)
}
func makeAttachmentHeader(disposition, filename, encoding, contentType, contentID string) textproto.MIMEHeader {
if encoding == "" {
encoding = "base64"
}
if contentType == "" {
contentType = "application/octet-stream"
}
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", disposition+"; filename="+filename)
h.Set("Content-Type", fmt.Sprintf("%s; name=\""+filename+"\"", contentType))
h.Set("Content-Transfer-Encoding", encoding)
if contentID != "" {
h.Set("Content-ID", "<"+contentID+">")
}
return h
}
func extractSrc(tag string) string {
m := reImgSrc.FindStringSubmatch(tag)
if len(m) == 0 {
return ""
}
if m[2] != "" {
return m[2]
}
return m[3]
}
+107
View File
@@ -0,0 +1,107 @@
package manager
import (
"bytes"
"fmt"
"source.offmarket.win/aleagle/eaglecast/models"
)
// NewCampaignMessage creates and returns a CampaignMessage that is made available
// to message templates while they're compiled. It represents a message from
// a campaign that's bound to a single Subscriber.
func (m *Manager) NewCampaignMessage(c *models.Campaign, s models.Subscriber) (CampaignMessage, error) {
msg := CampaignMessage{
Campaign: c,
Subscriber: s,
subject: c.Subject,
from: c.FromEmail,
to: s.Email,
unsubURL: fmt.Sprintf(m.cfg.UnsubURL, c.UUID, s.UUID),
}
if err := msg.render(); err != nil {
return msg, err
}
return msg, nil
}
// render takes a Message, executes its pre-compiled Campaign.Tpl
// and applies the resultant bytes to Message.body to be used in messages.
func (m *CampaignMessage) render() error {
out := bytes.Buffer{}
// Render the subject if it's a template.
if m.Campaign.SubjectTpl != nil {
if err := m.Campaign.SubjectTpl.ExecuteTemplate(&out, models.ContentTpl, m); err != nil {
return err
}
m.subject = out.String()
out.Reset()
}
// Compile the main template.
if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil {
return err
}
m.body = out.Bytes()
// Is there an alt body?
if m.Campaign.ContentType != models.CampaignContentTypePlain && m.Campaign.AltBody.Valid {
if m.Campaign.AltBodyTpl != nil {
b := bytes.Buffer{}
if err := m.Campaign.AltBodyTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil {
return err
}
m.altBody = b.Bytes()
} else {
m.altBody = []byte(m.Campaign.AltBody.String)
}
}
// If there are templated headers, compile them.
if m.Campaign.HeaderTpls == nil {
m.headers = m.Campaign.Headers
} else {
hdrOut := bytes.Buffer{}
m.headers = make(models.Headers, len(m.Campaign.Headers))
for i, set := range m.Campaign.Headers {
m.headers[i] = make(map[string]string, len(set))
for hdr, val := range set {
tpl := m.Campaign.HeaderTpls[i][hdr]
if tpl == nil {
m.headers[i][hdr] = val
continue
}
hdrOut.Reset()
if err := tpl.ExecuteTemplate(&hdrOut, models.ContentTpl, m); err != nil {
return fmt.Errorf("error rendering header %q: %v", hdr, err)
}
m.headers[i][hdr] = hdrOut.String()
}
}
}
return nil
}
// Subject returns a copy of the message subject
func (m *CampaignMessage) Subject() string {
return m.subject
}
// Body returns a copy of the message body.
func (m *CampaignMessage) Body() []byte {
out := make([]byte, len(m.body))
copy(out, m.body)
return out
}
// AltBody returns a copy of the message's alt body.
func (m *CampaignMessage) AltBody() []byte {
out := make([]byte, len(m.altBody))
copy(out, m.altBody)
return out
}
+244
View File
@@ -0,0 +1,244 @@
package manager
import (
"fmt"
"sync"
"sync/atomic"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/paulbellamy/ratecounter"
)
type pipe struct {
camp *models.Campaign
rate *ratecounter.RateCounter
wg *sync.WaitGroup
sent atomic.Int64
lastID atomic.Uint64
errors atomic.Uint64
stopped atomic.Bool
withErrors atomic.Bool
m *Manager
}
// newPipe adds a campaign to the process queue.
func (m *Manager) newPipe(c *models.Campaign) (*pipe, error) {
// Validate messenger.
if _, ok := m.messengers[c.Messenger]; !ok {
m.store.UpdateCampaignStatus(c.ID, models.CampaignStatusCancelled)
return nil, fmt.Errorf("unknown messenger %s on campaign %s", c.Messenger, c.Name)
}
// Resolve any inline images before compiling the template.
if err := m.LoadInlineImages(c); err != nil {
return nil, err
}
// Load the template.
if err := c.CompileTemplate(m.TemplateFuncs(c)); err != nil {
return nil, err
}
// Load any media/attachments.
if err := m.attachMedia(c); err != nil {
return nil, err
}
// Add the campaign to the active map.
p := &pipe{
camp: c,
rate: ratecounter.NewRateCounter(time.Minute),
wg: &sync.WaitGroup{},
m: m,
}
// Increment the waitgroup so that Wait() blocks immediately. This is necessary
// as a campaign pipe is created first and subscribers/messages under it are
// fetched asynchronolusly later. The messages each add to the wg and that
// count is used to determine the exhaustion/completion of all messages.
p.wg.Add(1)
go func() {
// Wait for all the messages in the campaign to be processed
// (successfully or skipped after errors or cancellation).
p.wg.Wait()
p.cleanup()
}()
m.pipesMut.Lock()
m.pipes[c.ID] = p
m.pipesMut.Unlock()
return p, nil
}
// NextSubscribers processes the next batch of subscribers in a given campaign.
// It returns a bool indicating whether any subscribers were processed
// in the current batch or not. A false indicates that all subscribers
// have been processed, or that a campaign has been paused or cancelled.
func (p *pipe) NextSubscribers() (bool, error) {
// Fetch the next batch of subscribers from a 'running' campaign.
subs, err := p.m.store.NextSubscribers(p.camp.ID, p.m.cfg.BatchSize)
if err != nil {
return false, fmt.Errorf("error fetching campaign subscribers (%s): %v", p.camp.Name, err)
}
// There are no subscribers from the query. Either all subscribers on the campaign
// have been processed, or the campaign has changed from 'running' to 'paused' or 'cancelled'.
if len(subs) == 0 {
return false, nil
}
// Is there a sliding window limit configured?
hasSliding := p.m.cfg.SlidingWindow &&
p.m.cfg.SlidingWindowRate > 0 &&
p.m.cfg.SlidingWindowDuration.Seconds() > 1
// Push messages.
for _, s := range subs {
msg, err := p.newMessage(s)
if err != nil {
p.m.log.Printf("error rendering message (%s) (%s): %v", p.camp.Name, s.Email, err)
continue
}
// Push the message to the queue while blocking and waiting until
// the queue is drained.
p.m.campMsgQ <- msg
// Check if the sliding window is active.
if hasSliding {
diff := time.Since(p.m.slidingStart)
// Window has expired. Reset the clock.
if diff >= p.m.cfg.SlidingWindowDuration {
p.m.slidingStart = time.Now()
p.m.slidingCount = 0
}
// Have the messages exceeded the limit?
p.m.slidingCount++
if p.m.slidingCount >= p.m.cfg.SlidingWindowRate {
wait := p.m.cfg.SlidingWindowDuration - diff
p.m.log.Printf("messages exceeded (%d) for the window (%v since %s). Sleeping for %s.",
p.m.slidingCount,
p.m.cfg.SlidingWindowDuration,
p.m.slidingStart.Format(time.RFC822Z),
wait.Round(time.Second)*1)
p.m.slidingCount = 0
time.Sleep(wait)
}
}
}
return true, nil
}
// OnError keeps track of the number of errors that occur while sending messages
// and pauses the campaign if the error threshold is met.
func (p *pipe) OnError() {
if p.m.cfg.MaxSendErrors < 1 {
return
}
// If the error threshold is met, pause the campaign.
count := p.errors.Add(1)
if int(count) < p.m.cfg.MaxSendErrors {
return
}
p.Stop(true)
p.m.log.Printf("error count exceeded %d. pausing campaign %s", p.m.cfg.MaxSendErrors, p.camp.Name)
}
// Stop "marks" a campaign as stopped. It doesn't actually stop the processing
// of messages. That happens when every queued message in the campaign is processed,
// marking .wg, the waitgroup counter as done. That triggers cleanup().
func (p *pipe) Stop(withErrors bool) {
// Already stopped.
if p.stopped.Load() {
return
}
if withErrors {
p.withErrors.Store(true)
}
p.stopped.Store(true)
}
// newMessage returns a campaign message while internally incrementing the
// number of messages in the pipe wait group so that the status of every
// message can be atomically tracked.
func (p *pipe) newMessage(s models.Subscriber) (CampaignMessage, error) {
msg, err := p.m.NewCampaignMessage(p.camp, s)
if err != nil {
return msg, err
}
msg.pipe = p
p.wg.Add(1)
return msg, nil
}
// cleanup finishes the campaign and updates the campaign status in the DB
// and also triggers a notification to the admin. This only triggers once
// a pipe's wg counter is fully exhausted, draining all messages in its queue.
func (p *pipe) cleanup() {
defer func() {
p.m.pipesMut.Lock()
delete(p.m.pipes, p.camp.ID)
p.m.pipesMut.Unlock()
}()
// Update campaign's 'sent count.
if err := p.m.store.UpdateCampaignCounts(p.camp.ID, 0, int(p.sent.Load()), int(p.lastID.Load())); err != nil {
p.m.log.Printf("error updating campaign counts (%s): %v", p.camp.Name, err)
}
// The campaign was auto-paused due to errors.
if p.withErrors.Load() {
if err := p.m.store.UpdateCampaignStatus(p.camp.ID, models.CampaignStatusPaused); err != nil {
p.m.log.Printf("error updating campaign (%s) status to %s: %v", p.camp.Name, models.CampaignStatusPaused, err)
} else {
p.m.log.Printf("set campaign (%s) to %s", p.camp.Name, models.CampaignStatusPaused)
}
_ = p.m.sendNotif(p.camp, models.CampaignStatusPaused, "Too many errors")
return
}
// The campaign was manually stopped (pause, cancel).
if p.stopped.Load() {
p.m.log.Printf("stop processing campaign (%s)", p.camp.Name)
return
}
// Campaign wasn't manually stopped and subscribers were naturally exhausted.
// Fetch the up-to-date campaign status from the DB.
c, err := p.m.store.GetCampaign(p.camp.ID)
if err != nil {
p.m.log.Printf("error fetching campaign (%s) for ending: %v", p.camp.Name, err)
return
}
// If a running campaign has exhausted subscribers, it's finished.
if c.Status == models.CampaignStatusRunning || c.Status == models.CampaignStatusScheduled {
c.Status = models.CampaignStatusFinished
if err := p.m.store.UpdateCampaignStatus(p.camp.ID, models.CampaignStatusFinished); err != nil {
p.m.log.Printf("error finishing campaign (%s): %v", p.camp.Name, err)
} else {
p.m.log.Printf("campaign (%s) finished", p.camp.Name)
}
} else {
p.m.log.Printf("finish processing campaign (%s)", p.camp.Name)
}
// Notify admin.
_ = p.m.sendNotif(c, c.Status, "")
}
+32
View File
@@ -0,0 +1,32 @@
package media
import (
"io"
"source.offmarket.win/aleagle/eaglecast/models"
"gopkg.in/volatiletech/null.v6"
)
// Media represents an uploaded object.
type Media struct {
ID int `db:"id" json:"id"`
UUID string `db:"uuid" json:"uuid"`
Filename string `db:"filename" json:"filename"`
ContentType string `db:"content_type" json:"content_type"`
Thumb string `db:"thumb" json:"-"`
CreatedAt null.Time `db:"created_at" json:"created_at"`
ThumbURL null.String `json:"thumb_url"`
Provider string `json:"provider"`
Meta models.JSON `db:"meta" json:"meta"`
URL string `json:"url"`
Total int `db:"total" json:"-"`
}
// Store represents functions to store and retrieve media (files).
type Store interface {
Put(string, string, io.ReadSeeker) (string, error)
Delete(string) error
GetURL(string) string
GetBlob(string) ([]byte, error)
}
@@ -0,0 +1,78 @@
package filesystem
import (
"fmt"
"io"
"os"
"path/filepath"
"source.offmarket.win/aleagle/eaglecast/internal/media"
)
// Opts represents filesystem params
type Opts struct {
UploadPath string `koanf:"upload_path"`
UploadURI string `koanf:"upload_uri"`
RootURL string `koanf:"root_url"`
}
// Client implements `media.Store`
type Client struct {
opts Opts
}
// New initialises store for Filesystem provider.
func New(opts Opts) (media.Store, error) {
return &Client{
opts: opts,
}, nil
}
// Put accepts the filename, the content type and file object itself and stores the file in disk.
func (c *Client) Put(filename string, cType string, src io.ReadSeeker) (string, error) {
// Get the directory path
dir := getDir(c.opts.UploadPath)
// Read the file contents.
out, err := os.OpenFile(filepath.Join(dir, filename), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664)
if err != nil {
return "", err
}
defer out.Close()
// Copy it to the target location.
if _, err := io.Copy(out, src); err != nil {
return "", err
}
return filename, nil
}
// GetURL accepts a filename and retrieves the full path from disk.
func (c *Client) GetURL(name string) string {
return fmt.Sprintf("%s%s/%s", c.opts.RootURL, c.opts.UploadURI, name)
}
// GetBlob accepts a URL, reads the file, and returns the blob.
func (c *Client) GetBlob(url string) ([]byte, error) {
b, err := os.ReadFile(filepath.Join(getDir(c.opts.UploadPath), filepath.Base(url)))
return b, err
}
// Delete accepts a filename and removes it from disk.
func (c *Client) Delete(file string) error {
dir := getDir(c.opts.UploadPath)
err := os.Remove(filepath.Join(dir, file))
return err
}
// getDir returns the current working directory path if no directory is specified,
// else returns the directory path specified itself.
func getDir(dir string) string {
if dir == "" {
dir, _ = os.Getwd()
}
return dir
}
+171
View File
@@ -0,0 +1,171 @@
package s3
import (
"fmt"
"io"
"net/url"
"path/filepath"
"strings"
"time"
"source.offmarket.win/aleagle/eaglecast/internal/media"
"github.com/rhnvrm/simples3"
)
// Opt represents AWS S3 specific params
type Opt struct {
URL string `koanf:"url"`
PublicURL string `koanf:"public_url"`
AccessKey string `koanf:"aws_access_key_id"`
SecretKey string `koanf:"aws_secret_access_key"`
Region string `koanf:"aws_default_region"`
Bucket string `koanf:"bucket"`
BucketPath string `koanf:"bucket_path"`
BucketType string `koanf:"bucket_type"`
Expiry time.Duration `koanf:"expiry"`
RootURL string `koanf:"root_url"`
}
// Client implements `media.Store` for S3 provider
type Client struct {
s3 *simples3.S3
opts Opt
}
// NewS3Store initialises store for S3 provider. It takes in the AWS configuration
// and sets up the `simples3` client to interact with AWS APIs for all bucket operations.
func NewS3Store(opt Opt) (media.Store, error) {
var cl *simples3.S3
if opt.URL == "" {
opt.URL = fmt.Sprintf("https://s3.%s.amazonaws.com", opt.Region)
}
opt.URL = strings.TrimRight(opt.URL, "/")
// Default (and max S3 expiry) is 7 days.
if opt.Expiry.Seconds() < 1 {
opt.Expiry = time.Duration(167) * time.Hour
}
if opt.AccessKey == "" && opt.SecretKey == "" {
// fallback to IAM role if no access key/secret key is provided.
cl, _ = simples3.NewUsingIAM(opt.Region)
}
if cl == nil {
cl = simples3.New(opt.Region, opt.AccessKey, opt.SecretKey)
}
cl.SetEndpoint(opt.URL)
return &Client{
s3: cl,
opts: opt,
}, nil
}
// Put takes in the filename, the content type and file object itself and uploads to S3.
func (c *Client) Put(name string, cType string, file io.ReadSeeker) (string, error) {
// Upload input parameters
p := simples3.UploadInput{
Bucket: c.opts.Bucket,
ContentType: cType,
FileName: name,
Body: file,
// Paths inside the bucket should not start with /.
ObjectKey: c.makeBucketPath(name),
}
if c.opts.BucketType == "public" {
p.ACL = "public-read"
}
// Upload.
if _, err := c.s3.FilePut(p); err != nil {
return "", err
}
return name, nil
}
// Get accepts the filename of the object stored and retrieves from S3.
func (c *Client) GetURL(name string) string {
// Generate a private S3 pre-signed URL if it's a private bucket, and there
// is no public URL provided.
if c.opts.BucketType == "private" && c.opts.PublicURL == "" {
u := c.s3.GeneratePresignedURL(simples3.PresignedInput{
Bucket: c.opts.Bucket,
ObjectKey: c.makeBucketPath(name),
Method: "GET",
Timestamp: time.Now(),
ExpirySeconds: int(c.opts.Expiry.Seconds()),
})
return u
}
// Generate a public S3 URL if it's a public bucket or a public URL is
// provided.
return c.makeFileURL(name)
}
// GetBlob reads a file from S3 and returns the raw bytes.
func (c *Client) GetBlob(uurl string) ([]byte, error) {
if p, err := url.Parse(uurl); err != nil {
uurl = filepath.Base(uurl)
} else {
uurl = filepath.Base(p.Path)
}
// Download the file from S3.
file, err := c.s3.FileDownload(simples3.DownloadInput{
Bucket: c.opts.Bucket,
ObjectKey: c.makeBucketPath(filepath.Base(uurl)),
})
if err != nil {
return nil, err
}
// Read it into a byte blob.
b, err := io.ReadAll(file)
if err != nil {
return nil, err
}
defer file.Close()
return b, nil
}
// Delete accepts the filename of the object and deletes from S3.
func (c *Client) Delete(name string) error {
err := c.s3.FileDelete(simples3.DeleteInput{
Bucket: c.opts.Bucket,
ObjectKey: c.makeBucketPath(name),
})
return err
}
// makeBucketPath returns the file path inside the bucket. The path should not
// start with a /.
func (c *Client) makeBucketPath(name string) string {
// If the path is root (/), return the filename without the preceding slash.
p := strings.TrimPrefix(strings.TrimSuffix(c.opts.BucketPath, "/"), "/")
if p == "" {
return name
}
// whatever/bucket/path/filename.jpg: No preceding slash.
return p + "/" + name
}
func (c *Client) makeFileURL(name string) string {
if c.opts.PublicURL != "" {
prefix := c.opts.PublicURL
if strings.HasPrefix(prefix, "/") {
prefix = c.opts.RootURL + prefix
}
return prefix + "/" + c.makeBucketPath(name)
}
return c.opts.URL + "/" + c.opts.Bucket + "/" + c.makeBucketPath(name)
}
+257
View File
@@ -0,0 +1,257 @@
package email
import (
"crypto/tls"
"fmt"
"math/rand"
"net/mail"
"net/smtp"
"net/textproto"
"strings"
"source.offmarket.win/aleagle/eaglecast/internal/utils"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/knadh/smtppool/v2"
)
const (
MessengerName = "email"
hdrReturnPath = "Return-Path"
hdrBcc = "Bcc"
hdrCc = "Cc"
hdrMessageID = "Message-Id"
)
// Server represents an SMTP server's credentials.
type Server struct {
// Name is a unique identifier for the server.
Name string `json:"name"`
Username string `json:"username"`
Password string `json:"password"`
AuthProtocol string `json:"auth_protocol"`
TLSType string `json:"tls_type"`
TLSSkipVerify bool `json:"tls_skip_verify"`
EmailHeaders map[string]string `json:"email_headers"`
FromAddresses []string `json:"from_addresses"`
// Rest of the options are embedded directly from the smtppool lib.
// The JSON tag is for config unmarshal to work.
//lint:ignore SA5008 ,squash is needed by koanf/mapstructure config unmarshal.
smtppool.Opt `json:",squash"`
pool *smtppool.Pool
}
// Emailer is the SMTP e-mail messenger.
type Emailer struct {
name string
// pools holds groups of SMTP servers indexed by a key ('from'-address
// or a domain set per SMTPs server). An empty key holds all servers
// and is the fallback round-robin when there's no match (old behaviour).
pools map[string][]*Server
}
// NormalizeAddr normalizes an e-mail address (strip spaces, lowercase).
func NormalizeAddr(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// New returns an SMTP e-mail Messenger backend with the given SMTP servers.
// Group indicates whether the messenger represents a group of SMTP servers (1 or more)
// that are used as a round-robin pool, or a single server.
func New(name string, servers ...Server) (*Emailer, error) {
e := &Emailer{
name: name,
pools: make(map[string][]*Server),
}
for _, srv := range servers {
s := srv
var auth smtp.Auth
switch s.AuthProtocol {
case "cram":
auth = smtp.CRAMMD5Auth(s.Username, s.Password)
case "plain":
auth = smtp.PlainAuth("", s.Username, s.Password, s.Host)
case "login":
auth = &smtppool.LoginAuth{Username: s.Username, Password: s.Password}
case "", "none":
default:
return nil, fmt.Errorf("unknown SMTP auth type '%s'", s.AuthProtocol)
}
s.Opt.Auth = auth
// TLS config.
s.Opt.SSL = smtppool.SSLNone
if s.TLSType != "none" {
s.TLSConfig = &tls.Config{}
if s.TLSSkipVerify {
s.TLSConfig.InsecureSkipVerify = s.TLSSkipVerify
} else {
s.TLSConfig.ServerName = s.Host
}
// SSL/TLS, not STARTTLS.
switch s.TLSType {
case "TLS":
s.Opt.SSL = smtppool.SSLTLS
case "STARTTLS":
s.Opt.SSL = smtppool.SSLSTARTTLS
}
}
pool, err := smtppool.New(s.Opt)
if err != nil {
return nil, err
}
s.pool = pool
// Add to the global list (empty key) and to each from-address
// bucket. Duplicate keys across servers are fine and get round-robin'd.
e.pools[""] = append(e.pools[""], &s)
for _, addr := range s.FromAddresses {
if key := NormalizeAddr(addr); key != "" {
e.pools[key] = append(e.pools[key], &s)
}
}
}
return e, nil
}
// Name returns the messenger's name.
func (e *Emailer) Name() string {
return e.name
}
// Push pushes a message to the server.
func (e *Emailer) Push(m models.Message) error {
// Pick the from-address-routed pool if there is one, else default
// to the full pool (empty key) for roundrobin.
pool := e.pools[""]
if len(e.pools) > 1 {
if srvs := e.getPool(m.From); srvs != nil {
pool = srvs
}
}
srv := pool[rand.Intn(len(pool))]
// Are there attachments?
var files []smtppool.Attachment
if m.Attachments != nil {
files = make([]smtppool.Attachment, 0, len(m.Attachments))
for _, f := range m.Attachments {
a := smtppool.Attachment{
Filename: f.Name,
Header: f.Header,
Content: make([]byte, len(f.Content)),
HTMLRelated: f.IsInline,
}
copy(a.Content, f.Content)
files = append(files, a)
}
}
// Create the email.
em := smtppool.Email{
From: m.From,
To: m.To,
Subject: m.Subject,
Attachments: files,
}
em.Headers = textproto.MIMEHeader{}
// Attach SMTP level headers.
for k, v := range srv.EmailHeaders {
em.Headers.Set(k, v)
}
// Attach e-mail level headers.
for k, v := range m.Headers {
em.Headers.Set(k, v[0])
}
// Generate Message-Id based on the From address.
if em.Headers.Get(hdrMessageID) == "" {
d := "localhost"
if a, err := mail.ParseAddress(m.From); err == nil {
d = a.Address[strings.LastIndex(a.Address, "@")+1:]
}
if r, err := utils.GenerateRandomString(24); err == nil {
em.Headers.Set(hdrMessageID, fmt.Sprintf("<%s@%s>", r, d))
}
}
// If the `Return-Path` header is set, it should be set as the
// the SMTP envelope sender (via the Sender field of the email struct).
if sender := em.Headers.Get(hdrReturnPath); sender != "" {
em.Sender = sender
em.Headers.Del(hdrReturnPath)
}
// If the `Bcc` header is set, it should be set on the Envelope
if bcc := em.Headers.Get(hdrBcc); bcc != "" {
for _, part := range strings.Split(bcc, ",") {
em.Bcc = append(em.Bcc, strings.TrimSpace(part))
}
em.Headers.Del(hdrBcc)
}
// If the `Cc` header is set, it should be set on the Envelope
if cc := em.Headers.Get(hdrCc); cc != "" {
for _, part := range strings.Split(cc, ",") {
em.Cc = append(em.Cc, strings.TrimSpace(part))
}
em.Headers.Del(hdrCc)
}
switch m.ContentType {
case "plain":
em.Text = []byte(m.Body)
default:
em.HTML = m.Body
if len(m.AltBody) > 0 {
em.Text = m.AltBody
}
}
return srv.pool.Send(em)
}
// Flush flushes the message queue to the server.
func (e *Emailer) Flush() error {
return nil
}
// Close closes the SMTP pools.
func (e *Emailer) Close() error {
for _, s := range e.pools[""] {
s.pool.Close()
}
return nil
}
// getPool returns the pool of servers configured to handle the given From
// header, matched by full e-mail and then by domain.
// Returns nil if no mapping matches.
func (e *Emailer) getPool(from string) []*Server {
addr := utils.ParseEmailAddress(from)
if addr == "" {
return nil
}
if srvs, ok := e.pools[addr]; ok {
return srvs
}
if _, after, ok := strings.Cut(addr, "@"); ok {
return e.pools[after]
}
return nil
}
+212
View File
@@ -0,0 +1,212 @@
package postback
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/textproto"
"time"
"source.offmarket.win/aleagle/eaglecast/models"
)
// postback is the payload that's posted as JSON to the HTTP Postback server.
//
//easyjson:json
type postback struct {
Subject string `json:"subject"`
FromEmail string `json:"from_email"`
ContentType string `json:"content_type"`
Body string `json:"body"`
Recipients []recipient `json:"recipients"`
Campaign *campaign `json:"campaign"`
Attachments []attachment `json:"attachments"`
}
type campaign struct {
FromEmail string `json:"from_email"`
UUID string `json:"uuid"`
Name string `json:"name"`
Headers models.Headers `json:"headers"`
Tags []string `json:"tags"`
}
type recipient struct {
UUID string `json:"uuid"`
Email string `json:"email"`
Name string `json:"name"`
Attribs models.JSON `json:"attribs"`
Status string `json:"status"`
}
type attachment struct {
Name string `json:"name"`
Header textproto.MIMEHeader `json:"header"`
Content []byte `json:"content"`
}
// Options represents HTTP Postback server options.
type Options struct {
Name string `json:"name"`
Username string `json:"username"`
Password string `json:"password"`
RootURL string `json:"root_url"`
MaxConns int `json:"max_conns"`
Retries int `json:"retries"`
Timeout time.Duration `json:"timeout"`
}
// Postback represents an HTTP Message server.
type Postback struct {
authStr string
o Options
c *http.Client
}
// New returns a new instance of the HTTP Postback messenger.
func New(o Options) (*Postback, error) {
authStr := ""
if o.Username != "" && o.Password != "" {
authStr = fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString(
[]byte(o.Username+":"+o.Password)))
}
return &Postback{
authStr: authStr,
o: o,
c: &http.Client{
Timeout: o.Timeout,
Transport: &http.Transport{
MaxIdleConnsPerHost: o.MaxConns,
MaxConnsPerHost: o.MaxConns,
ResponseHeaderTimeout: o.Timeout,
IdleConnTimeout: o.Timeout,
},
},
}, nil
}
// Name returns the messenger's name.
func (p *Postback) Name() string {
return p.o.Name
}
// Push pushes a message to the server.
func (p *Postback) Push(m models.Message) error {
pb := postback{
Subject: m.Subject,
FromEmail: m.From,
ContentType: m.ContentType,
Body: string(m.Body),
Recipients: []recipient{{
UUID: m.Subscriber.UUID,
Email: m.Subscriber.Email,
Name: m.Subscriber.Name,
Status: m.Subscriber.Status,
Attribs: m.Subscriber.Attribs,
}},
}
if m.Campaign != nil {
pb.Campaign = &campaign{
FromEmail: m.Campaign.FromEmail,
UUID: m.Campaign.UUID,
Name: m.Campaign.Name,
Headers: m.Campaign.Headers,
Tags: m.Campaign.Tags,
}
}
if len(m.Attachments) > 0 {
files := make([]attachment, 0, len(m.Attachments))
for _, f := range m.Attachments {
a := attachment{
Name: f.Name,
Header: f.Header,
Content: make([]byte, len(f.Content)),
}
copy(a.Content, f.Content)
files = append(files, a)
}
pb.Attachments = files
}
b, err := pb.MarshalJSON()
if err != nil {
return err
}
return p.exec(http.MethodPost, p.o.RootURL, b, nil)
}
// Flush flushes the message queue to the server.
func (p *Postback) Flush() error {
return nil
}
// Close closes idle HTTP connections.
func (p *Postback) Close() error {
p.c.CloseIdleConnections()
return nil
}
func (p *Postback) exec(method, rURL string, reqBody []byte, headers http.Header) error {
var (
err error
postBody io.Reader
)
// Encode POST / PUT params.
if method == http.MethodPost || method == http.MethodPut {
postBody = bytes.NewReader(reqBody)
}
req, err := http.NewRequest(method, rURL, postBody)
if err != nil {
return err
}
if headers != nil {
req.Header = headers
} else {
req.Header = http.Header{}
}
req.Header.Set("User-Agent", "eaglecast")
// Optional BasicAuth.
if p.authStr != "" {
req.Header.Set("Authorization", p.authStr)
}
// If a content-type isn't set, set the default one.
if req.Header.Get("Content-Type") == "" {
if method == http.MethodPost || method == http.MethodPut {
req.Header.Add("Content-Type", "application/json")
}
}
// If the request method is GET or DELETE, add the params as QueryString.
if method == http.MethodGet || method == http.MethodDelete {
req.URL.RawQuery = string(reqBody)
}
// Execute the request.
r, err := p.c.Do(req)
if err != nil {
return err
}
defer func() {
// Drain and close the body to let the Transport reuse the connection
io.Copy(io.Discard, r.Body)
r.Body.Close()
}()
if r.StatusCode != http.StatusOK {
return fmt.Errorf("non-OK response from Postback server: %d", r.StatusCode)
}
return nil
}
@@ -0,0 +1,604 @@
// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.
package postback
import (
json "encoding/json"
models "source.offmarket.win/aleagle/eaglecast/models"
easyjson "github.com/zerodha/easyjson"
jlexer "github.com/zerodha/easyjson/jlexer"
jwriter "github.com/zerodha/easyjson/jwriter"
textproto "net/textproto"
)
// suppress unused package warning
var (
_ *json.RawMessage
_ *jlexer.Lexer
_ *jwriter.Writer
_ easyjson.Marshaler
)
func easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback(in *jlexer.Lexer, out *postback) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
in.Consumed()
}
in.Skip()
return
}
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeFieldName(false)
in.WantColon()
if in.IsNull() {
in.Skip()
in.WantComma()
continue
}
switch key {
case "subject":
out.Subject = string(in.String())
case "from_email":
out.FromEmail = string(in.String())
case "content_type":
out.ContentType = string(in.String())
case "body":
out.Body = string(in.String())
case "recipients":
if in.IsNull() {
in.Skip()
out.Recipients = nil
} else {
in.Delim('[')
if out.Recipients == nil {
if !in.IsDelim(']') {
out.Recipients = make([]recipient, 0, 0)
} else {
out.Recipients = []recipient{}
}
} else {
out.Recipients = (out.Recipients)[:0]
}
for !in.IsDelim(']') {
var v1 recipient
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback1(in, &v1)
out.Recipients = append(out.Recipients, v1)
in.WantComma()
}
in.Delim(']')
}
case "campaign":
if in.IsNull() {
in.Skip()
out.Campaign = nil
} else {
if out.Campaign == nil {
out.Campaign = new(campaign)
}
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback2(in, out.Campaign)
}
case "attachments":
if in.IsNull() {
in.Skip()
out.Attachments = nil
} else {
in.Delim('[')
if out.Attachments == nil {
if !in.IsDelim(']') {
out.Attachments = make([]attachment, 0, 1)
} else {
out.Attachments = []attachment{}
}
} else {
out.Attachments = (out.Attachments)[:0]
}
for !in.IsDelim(']') {
var v2 attachment
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback3(in, &v2)
out.Attachments = append(out.Attachments, v2)
in.WantComma()
}
in.Delim(']')
}
default:
in.SkipRecursive()
}
in.WantComma()
}
in.Delim('}')
if isTopLevel {
in.Consumed()
}
}
func easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback(out *jwriter.Writer, in postback) {
out.RawByte('{')
first := true
_ = first
{
const prefix string = ",\"subject\":"
out.RawString(prefix[1:])
out.String(string(in.Subject))
}
{
const prefix string = ",\"from_email\":"
out.RawString(prefix)
out.String(string(in.FromEmail))
}
{
const prefix string = ",\"content_type\":"
out.RawString(prefix)
out.String(string(in.ContentType))
}
{
const prefix string = ",\"body\":"
out.RawString(prefix)
out.String(string(in.Body))
}
{
const prefix string = ",\"recipients\":"
out.RawString(prefix)
if in.Recipients == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v3, v4 := range in.Recipients {
if v3 > 0 {
out.RawByte(',')
}
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback1(out, v4)
}
out.RawByte(']')
}
}
{
const prefix string = ",\"campaign\":"
out.RawString(prefix)
if in.Campaign == nil {
out.RawString("null")
} else {
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback2(out, *in.Campaign)
}
}
{
const prefix string = ",\"attachments\":"
out.RawString(prefix)
if in.Attachments == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v5, v6 := range in.Attachments {
if v5 > 0 {
out.RawByte(',')
}
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback3(out, v6)
}
out.RawByte(']')
}
}
out.RawByte('}')
}
// MarshalJSON supports json.Marshaler interface
func (v postback) MarshalJSON() ([]byte, error) {
w := jwriter.Writer{FloatFmt: ""}
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback(&w, v)
return w.Buffer.BuildBytes(), w.Error
}
// MarshalEasyJSON supports easyjson.Marshaler interface
func (v postback) MarshalEasyJSON(w *jwriter.Writer) {
easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback(w, v)
}
// UnmarshalJSON supports json.Unmarshaler interface
func (v *postback) UnmarshalJSON(data []byte) error {
r := jlexer.Lexer{Data: data}
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback(&r, v)
return r.Error()
}
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
func (v *postback) UnmarshalEasyJSON(l *jlexer.Lexer) {
easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback(l, v)
}
func easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback3(in *jlexer.Lexer, out *attachment) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
in.Consumed()
}
in.Skip()
return
}
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeFieldName(false)
in.WantColon()
if in.IsNull() {
in.Skip()
in.WantComma()
continue
}
switch key {
case "name":
out.Name = string(in.String())
case "header":
if in.IsNull() {
in.Skip()
} else {
in.Delim('{')
out.Header = make(textproto.MIMEHeader)
for !in.IsDelim('}') {
key := string(in.String())
in.WantColon()
var v7 []string
if in.IsNull() {
in.Skip()
v7 = nil
} else {
in.Delim('[')
if v7 == nil {
if !in.IsDelim(']') {
v7 = make([]string, 0, 4)
} else {
v7 = []string{}
}
} else {
v7 = (v7)[:0]
}
for !in.IsDelim(']') {
var v8 string
v8 = string(in.String())
v7 = append(v7, v8)
in.WantComma()
}
in.Delim(']')
}
(out.Header)[key] = v7
in.WantComma()
}
in.Delim('}')
}
case "content":
if in.IsNull() {
in.Skip()
out.Content = nil
} else {
out.Content = in.Bytes()
}
default:
in.SkipRecursive()
}
in.WantComma()
}
in.Delim('}')
if isTopLevel {
in.Consumed()
}
}
func easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback3(out *jwriter.Writer, in attachment) {
out.RawByte('{')
first := true
_ = first
{
const prefix string = ",\"name\":"
out.RawString(prefix[1:])
out.String(string(in.Name))
}
{
const prefix string = ",\"header\":"
out.RawString(prefix)
if in.Header == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 {
out.RawString(`null`)
} else {
out.RawByte('{')
v10First := true
for v10Name, v10Value := range in.Header {
if v10First {
v10First = false
} else {
out.RawByte(',')
}
out.String(string(v10Name))
out.RawByte(':')
if v10Value == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v11, v12 := range v10Value {
if v11 > 0 {
out.RawByte(',')
}
out.String(string(v12))
}
out.RawByte(']')
}
}
out.RawByte('}')
}
}
{
const prefix string = ",\"content\":"
out.RawString(prefix)
out.Base64Bytes(in.Content)
}
out.RawByte('}')
}
func easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback2(in *jlexer.Lexer, out *campaign) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
in.Consumed()
}
in.Skip()
return
}
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeFieldName(false)
in.WantColon()
if in.IsNull() {
in.Skip()
in.WantComma()
continue
}
switch key {
case "from_email":
out.FromEmail = string(in.String())
case "uuid":
out.UUID = string(in.String())
case "name":
out.Name = string(in.String())
case "headers":
if in.IsNull() {
in.Skip()
out.Headers = nil
} else {
in.Delim('[')
if out.Headers == nil {
if !in.IsDelim(']') {
out.Headers = make(models.Headers, 0, 8)
} else {
out.Headers = models.Headers{}
}
} else {
out.Headers = (out.Headers)[:0]
}
for !in.IsDelim(']') {
var v15 map[string]string
if in.IsNull() {
in.Skip()
} else {
in.Delim('{')
v15 = make(map[string]string)
for !in.IsDelim('}') {
key := string(in.String())
in.WantColon()
var v16 string
v16 = string(in.String())
(v15)[key] = v16
in.WantComma()
}
in.Delim('}')
}
out.Headers = append(out.Headers, v15)
in.WantComma()
}
in.Delim(']')
}
case "tags":
if in.IsNull() {
in.Skip()
out.Tags = nil
} else {
in.Delim('[')
if out.Tags == nil {
if !in.IsDelim(']') {
out.Tags = make([]string, 0, 4)
} else {
out.Tags = []string{}
}
} else {
out.Tags = (out.Tags)[:0]
}
for !in.IsDelim(']') {
var v17 string
v17 = string(in.String())
out.Tags = append(out.Tags, v17)
in.WantComma()
}
in.Delim(']')
}
default:
in.SkipRecursive()
}
in.WantComma()
}
in.Delim('}')
if isTopLevel {
in.Consumed()
}
}
func easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback2(out *jwriter.Writer, in campaign) {
out.RawByte('{')
first := true
_ = first
{
const prefix string = ",\"from_email\":"
out.RawString(prefix[1:])
out.String(string(in.FromEmail))
}
{
const prefix string = ",\"uuid\":"
out.RawString(prefix)
out.String(string(in.UUID))
}
{
const prefix string = ",\"name\":"
out.RawString(prefix)
out.String(string(in.Name))
}
{
const prefix string = ",\"headers\":"
out.RawString(prefix)
if in.Headers == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v18, v19 := range in.Headers {
if v18 > 0 {
out.RawByte(',')
}
if v19 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 {
out.RawString(`null`)
} else {
out.RawByte('{')
v20First := true
for v20Name, v20Value := range v19 {
if v20First {
v20First = false
} else {
out.RawByte(',')
}
out.String(string(v20Name))
out.RawByte(':')
out.String(string(v20Value))
}
out.RawByte('}')
}
}
out.RawByte(']')
}
}
{
const prefix string = ",\"tags\":"
out.RawString(prefix)
if in.Tags == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
out.RawString("null")
} else {
out.RawByte('[')
for v21, v22 := range in.Tags {
if v21 > 0 {
out.RawByte(',')
}
out.String(string(v22))
}
out.RawByte(']')
}
}
out.RawByte('}')
}
func easyjsonDf11841fDecodeGithubComKnadhEagleCastInternalMessengerPostback1(in *jlexer.Lexer, out *recipient) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
in.Consumed()
}
in.Skip()
return
}
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeFieldName(false)
in.WantColon()
if in.IsNull() {
in.Skip()
in.WantComma()
continue
}
switch key {
case "uuid":
out.UUID = string(in.String())
case "email":
out.Email = string(in.String())
case "name":
out.Name = string(in.String())
case "attribs":
if in.IsNull() {
in.Skip()
} else {
in.Delim('{')
out.Attribs = make(models.JSON)
for !in.IsDelim('}') {
key := string(in.String())
in.WantColon()
var v23 interface{}
if m, ok := v23.(easyjson.Unmarshaler); ok {
m.UnmarshalEasyJSON(in)
} else if m, ok := v23.(json.Unmarshaler); ok {
_ = m.UnmarshalJSON(in.Raw())
} else {
v23 = in.Interface()
}
(out.Attribs)[key] = v23
in.WantComma()
}
in.Delim('}')
}
case "status":
out.Status = string(in.String())
default:
in.SkipRecursive()
}
in.WantComma()
}
in.Delim('}')
if isTopLevel {
in.Consumed()
}
}
func easyjsonDf11841fEncodeGithubComKnadhEagleCastInternalMessengerPostback1(out *jwriter.Writer, in recipient) {
out.RawByte('{')
first := true
_ = first
{
const prefix string = ",\"uuid\":"
out.RawString(prefix[1:])
out.String(string(in.UUID))
}
{
const prefix string = ",\"email\":"
out.RawString(prefix)
out.String(string(in.Email))
}
{
const prefix string = ",\"name\":"
out.RawString(prefix)
out.String(string(in.Name))
}
{
const prefix string = ",\"attribs\":"
out.RawString(prefix)
if in.Attribs == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 {
out.RawString(`null`)
} else {
out.RawByte('{')
v24First := true
for v24Name, v24Value := range in.Attribs {
if v24First {
v24First = false
} else {
out.RawByte(',')
}
out.String(string(v24Name))
out.RawByte(':')
if m, ok := v24Value.(easyjson.Marshaler); ok {
m.MarshalEasyJSON(out)
} else if m, ok := v24Value.(json.Marshaler); ok {
out.Raw(m.MarshalJSON())
} else {
out.Raw(json.Marshal(v24Value))
}
}
out.RawByte('}')
}
}
{
const prefix string = ",\"status\":"
out.RawString(prefix)
out.String(string(in.Status))
}
out.RawByte('}')
}
+28
View File
@@ -0,0 +1,28 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_4_0 performs the DB migrations for v.0.4.0.
func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
_, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_optin') THEN
CREATE TYPE list_optin AS ENUM ('single', 'double');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'campaign_type') THEN
CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
END IF;
END$$;
ALTER TABLE lists ADD COLUMN IF NOT EXISTS optin list_optin NOT NULL DEFAULT 'single';
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS type campaign_type DEFAULT 'regular';
`)
return err
}
+116
View File
@@ -0,0 +1,116 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_7_0 performs the DB migrations for v.0.7.0.
func V0_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Check if the subscriber_status.blocklisted enum value exists. If not,
// it has to be created (for the change from blacklisted -> blocklisted).
var bl bool
if err := db.Get(&bl, `SELECT 'blocklisted' = ANY(ENUM_RANGE(NULL::subscriber_status)::TEXT[])`); err != nil {
return err
}
// If `blocklist` doesn't exist, add it to the subscriber_status enum,
// and update existing statuses to this value. Unfortunately, it's not possible
// to remove the enum value `blacklisted` (until PG10).
if !bl {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`
-- Change the status column to text.
ALTER TABLE subscribers ALTER COLUMN status TYPE TEXT;
-- Change all statuses from 'blacklisted' to 'blocklisted'.
UPDATE subscribers SET status='blocklisted' WHERE status='blacklisted';
-- Remove the old enum.
DROP TYPE subscriber_status CASCADE;
-- Create new enum with the new values.
CREATE TYPE subscriber_status AS ENUM ('enabled', 'disabled', 'blocklisted');
-- Change the text status column to the new enum.
ALTER TABLE subscribers ALTER COLUMN status TYPE subscriber_status
USING (status::subscriber_status);
`); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
_, err := db.Exec(`
ALTER TABLE media DROP COLUMN IF EXISTS width,
DROP COLUMN IF EXISTS height,
ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT '';
-- 'blacklisted' to 'blocklisted' ENUM rename is not possible (until pg10),
-- so just add the new value and ignore the old one.
CREATE TABLE IF NOT EXISTS settings (
key TEXT NOT NULL UNIQUE,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key);
-- Insert default settings if the table is empty.
INSERT INTO settings (key, value) SELECT k, v::JSONB FROM (VALUES
('app.root_url', '"http://localhost:9000"'),
('app.favicon_url', '""'),
('app.from_email', '"eaglecast <noreply@eaglecast.yoursite.com>"'),
('app.logo_url', '"http://localhost:9000/public/static/logo.png"'),
('app.concurrency', '10'),
('app.message_rate', '10'),
('app.batch_size', '1000'),
('app.max_send_errors', '1000'),
('app.notify_emails', '[]'),
('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'),
('privacy.allow_export', 'true'),
('privacy.allow_wipe', 'true'),
('privacy.exportable', '["profile", "subscriptions", "campaign_views", "link_clicks"]'),
('upload.provider', '"filesystem"'),
('upload.filesystem.upload_path', '"uploads"'),
('upload.filesystem.upload_uri', '"/uploads"'),
('upload.s3.aws_access_key_id', '""'),
('upload.s3.aws_secret_access_key', '""'),
('upload.s3.aws_default_region', '"ap-south-1"'),
('upload.s3.bucket', '""'),
('upload.s3.bucket_domain', '""'),
('upload.s3.bucket_path', '"/"'),
('upload.s3.bucket_type', '"public"'),
('upload.s3.expiry', '"14d"'),
('smtp',
'[{"enabled":true, "host":"smtp.yoursite.com","port":25,"auth_protocol":"cram","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":true,"tls_skip_verify":false,"email_headers":[]},
{"enabled":false, "host":"smtp2.yoursite.com","port":587,"auth_protocol":"plain","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":false,"tls_skip_verify":false,"email_headers":[]}]'),
('messengers', '[]')) vals(k, v) WHERE NOT EXISTS(SELECT * FROM settings LIMIT 1);
`)
if err != nil {
return err
}
// `provider` in the media table is a new field. If there's provider config available
// and no provider value exists in the media table, set it.
prov := ko.String("upload.provider")
if prov != "" {
if _, err := db.Exec(`UPDATE media SET provider=$1 WHERE provider=''`, prov); err != nil {
return err
}
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_8_0 performs the DB migrations for v.0.8.0.
func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
_, err := db.Exec(`
INSERT INTO settings (key, value) VALUES ('privacy.individual_tracking', 'false')
ON CONFLICT DO NOTHING;
INSERT INTO settings (key, value) VALUES ('messengers', '[]')
ON CONFLICT DO NOTHING;
-- Link clicks shouldn't exist if there's no corresponding link.
-- links_clicks.link_id should have been NOT NULL originally.
DELETE FROM link_clicks WHERE link_id is NULL;
ALTER TABLE link_clicks ALTER COLUMN link_id SET NOT NULL;
`)
return err
}
+42
View File
@@ -0,0 +1,42 @@
package migrations
import (
"fmt"
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_9_0 performs the DB migrations for v.0.9.0.
func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('app.lang', '"en"'),
('app.message_sliding_window', 'false'),
('app.message_sliding_window_duration', '"1h"'),
('app.message_sliding_window_rate', '10000'),
('app.enable_public_subscription_page', 'true')
ON CONFLICT DO NOTHING;
-- Add alternate (plain text) body field on campaigns.
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS altbody TEXT NULL DEFAULT NULL;
`); err != nil {
return err
}
// Until this version, the default template during installation was broken!
// Check if there's a broken default template and if yes, override it with the
// actual one.
tplBody, err := fs.Get("/static/email-templates/default.tpl")
if err != nil {
return fmt.Errorf("error reading default e-mail template: %v", err)
}
if _, err := db.Exec(`UPDATE templates SET body=$1 WHERE body=$2`,
tplBody.ReadBytes(), `{{ template "content" . }}`); err != nil {
return err
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V1_0_0 performs the DB migrations for v.1.0.0.
func V1_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'markdown'`); err != nil {
return err
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_0_0 performs the DB migrations for v.1.0.0.
func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'bounce_type') THEN
CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
END IF;
END$$;
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS bounces (
id SERIAL PRIMARY KEY,
subscriber_id INTEGER NOT NULL REFERENCES subscribers(id) ON DELETE CASCADE ON UPDATE CASCADE,
campaign_id INTEGER NULL REFERENCES campaigns(id) ON DELETE SET NULL ON UPDATE CASCADE,
type bounce_type NOT NULL DEFAULT 'hard',
source TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bounces_sub_id ON bounces(subscriber_id);
CREATE INDEX IF NOT EXISTS idx_bounces_camp_id ON bounces(campaign_id);
CREATE INDEX IF NOT EXISTS idx_bounces_source ON bounces(source);
`); err != nil {
return err
}
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('app.send_optin_confirmation', 'true'),
('privacy.domain_blocklist', '[]'),
('bounce.enabled', 'false'),
('bounce.webhooks_enabled', 'false'),
('bounce.count', '2'),
('bounce.action', '"blocklist"'),
('bounce.ses_enabled', 'false'),
('bounce.sendgrid_enabled', 'false'),
('bounce.sendgrid_key', '""'),
('bounce.mailboxes', '[{"enabled":false, "type": "pop", "host":"pop.yoursite.com","port":995,"auth_protocol":"userpass","username":"username","password":"password","return_path": "bounce@eaglecast.yoursite.com","scan_interval":"15m","tls_enabled":true,"tls_skip_verify":false}]')
ON CONFLICT DO NOTHING;`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE subscribers DROP COLUMN IF EXISTS campaigns`); err != nil {
return err
}
if _, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_views_pkey') THEN
ALTER TABLE campaign_views ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'link_clicks_pkey') THEN
ALTER TABLE link_clicks ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_lists_pkey') THEN
ALTER TABLE campaign_lists ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
END IF;
END$$;
CREATE INDEX IF NOT EXISTS idx_views_date ON campaign_views((TIMEZONE('UTC', created_at)::DATE));
CREATE INDEX IF NOT EXISTS idx_clicks_date ON link_clicks((TIMEZONE('UTC', created_at)::DATE));
`); err != nil {
return err
}
// S3 URL i snow a settings field. Prepare S3 URL based on region and bucket.
if _, err := db.Exec(`
WITH region AS (
SELECT value#>>'{}' AS value FROM settings WHERE key='upload.s3.aws_default_region'
), s3url AS (
SELECT FORMAT('https://s3.%s.amazonaws.com', (SELECT value FROM region)) AS value
)
INSERT INTO settings (key, value) VALUES ('upload.s3.url', TO_JSON((SELECT * FROM s3url))) ON CONFLICT DO NOTHING;`); err != nil {
return err
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_1_0 performs the DB migrations for v.2.1.0.
func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Insert appearance related settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('appearance.admin.custom_css', '""'),
('appearance.admin.custom_js', '""'),
('appearance.public.custom_css', '""'),
('appearance.public.custom_js', '""'),
('upload.s3.public_url', '""')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Replace all `tls_enabled: true/false` keys in the `smtp` settings JSON array
// with the new field `tls_type: STARTTLS|TLS|none`.
// The `tls_enabled` key is removed.
if _, err := db.Exec(`
UPDATE settings SET value = s.updated
FROM (
SELECT JSONB_AGG(
JSONB_SET(v - 'tls_enabled', '{tls_type}', (CASE WHEN v->>'tls_enabled' = 'true' THEN '"STARTTLS"' ELSE '"none"' END)::JSONB)
) AS updated FROM settings, JSONB_ARRAY_ELEMENTS(value) v WHERE key = 'smtp'
) s WHERE key = 'smtp' AND value::TEXT LIKE '%tls_enabled%';
`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS headers JSONB NOT NULL DEFAULT '[]';`); err != nil {
return err
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_2_0 performs the DB migrations for v.2.2.0.
func V2_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_type') THEN
CREATE TYPE template_type AS ENUM ('campaign', 'tx');
END IF;
END$$;
`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS "type" template_type NOT NULL DEFAULT 'campaign'`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS subject TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE templates ALTER COLUMN subject DROP DEFAULT`); err != nil {
return err
}
// Insert transactional template.
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
if err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO templates (name, type, subject, body) VALUES($1, $2, $3, $4)`,
"Sample transactional template", "tx", "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
return err
}
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_2_0 performs the DB migrations for v.2.3.0.
func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS "meta" JSONB NOT NULL DEFAULT '{}'`); err != nil {
return err
}
// Add `description` field to lists.
if _, err := db.Exec(`ALTER TABLE lists ADD COLUMN IF NOT EXISTS "description" TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
// Add archive publishing field to campaigns.
if _, err := db.Exec(`ALTER TABLE campaigns
ADD COLUMN IF NOT EXISTS archive BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS archive_meta JSONB NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1
`); err != nil {
return err
}
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('app.site_name', '"Mailing list"'),
('app.enable_public_archive', 'true'),
('privacy.allow_preferences', 'false')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_4_0 performs the DB migrations.
func V2_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('security.enable_captcha', 'false'),
('security.captcha_key', '""'),
('security.captcha_secret', '""')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V2_5_0 performs the DB migrations.
func V2_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('upload.extensions', '["jpg","jpeg","png","gif","svg","*"]'),
('app.enable_public_archive_rss_content', 'false'),
('bounce.actions', '{"soft": {"count": 2, "action": "none"}, "hard": {"count": 2, "action": "blocklist"}, "complaint" : {"count": 2, "action": "blocklist"}}'),
('privacy.record_optin_ip', 'false')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
if _, err := db.Exec(`
DELETE FROM settings WHERE key IN ('bounce.count', 'bounce.action');
-- Add the content_type column.
ALTER TABLE media ADD COLUMN IF NOT EXISTS content_type TEXT NOT NULL DEFAULT 'application/octet-stream';
-- Add meta column to subscriptions.
ALTER TABLE subscriber_lists ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}';
-- Fill the content type column for existing files (which would only be images at this point).
UPDATE media SET content_type = CASE
WHEN LOWER(SUBSTRING(filename FROM '.([^.]+)$')) = 'svg' THEN 'image/svg+xml'
ELSE 'image/' || LOWER(SUBSTRING(filename FROM '.([^.]+)$'))
END;
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS campaign_media (
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
-- Media items may be deleted, so media_id is nullable
-- and a copy of the original name is maintained here.
media_id INTEGER NULL REFERENCES media(id) ON DELETE SET NULL ON UPDATE CASCADE,
filename TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_camp_media_id ON campaign_media (campaign_id, media_id);
CREATE INDEX IF NOT EXISTS idx_camp_media_camp_id ON campaign_media(campaign_id);
`); err != nil {
return err
}
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V3_0_0 performs the DB migrations.
func V3_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('bounce.postmark', '{"enabled": false, "username": "", "password": ""}'),
('app.cache_slow_queries', 'false'),
('app.cache_slow_queries_interval', '"0 3 * * *"')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Fix incorrect "d" (day) time prefix in S3 expiry settings.
if _, err := db.Exec(`UPDATE settings SET value = '"167h"' WHERE key = 'upload.s3.expiry' AND value = '"14d"'`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS archive_slug TEXT NULL UNIQUE`); err != nil {
return err
}
// Add indexes that make sorting faster on large tables.
if _, err := db.Exec(`
CREATE INDEX IF NOT EXISTS idx_subs_created_at ON subscribers(created_at);
CREATE INDEX IF NOT EXISTS idx_subs_updated_at ON subscribers(updated_at);
CREATE INDEX IF NOT EXISTS idx_camps_status ON campaigns(status);
CREATE INDEX IF NOT EXISTS idx_camps_name ON campaigns(name);
CREATE INDEX IF NOT EXISTS idx_camps_created_at ON campaigns(created_at);
CREATE INDEX IF NOT EXISTS idx_camps_updated_at ON campaigns(updated_at);
CREATE INDEX IF NOT EXISTS idx_lists_type ON lists(type);
CREATE INDEX IF NOT EXISTS idx_lists_optin ON lists(optin);
CREATE INDEX IF NOT EXISTS idx_lists_name ON lists(name);
CREATE INDEX IF NOT EXISTS idx_lists_created_at ON lists(created_at);
CREATE INDEX IF NOT EXISTS idx_lists_updated_at ON lists(updated_at);
`); err != nil {
return err
}
// Create materialized views for slow aggregate queries.
if _, err := db.Exec(`
-- dashboard stats
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_counts AS
WITH subs AS (
SELECT COUNT(*) AS num, status FROM subscribers GROUP BY status
)
SELECT NOW() AS updated_at,
JSON_BUILD_OBJECT(
'subscribers', JSON_BUILD_OBJECT(
'total', (SELECT SUM(num) FROM subs),
'blocklisted', (SELECT num FROM subs WHERE status='blocklisted'),
'orphans', (
SELECT COUNT(id) FROM subscribers
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
WHERE subscriber_lists.subscriber_id IS NULL
)
),
'lists', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM lists),
'private', (SELECT COUNT(*) FROM lists WHERE type='private'),
'public', (SELECT COUNT(*) FROM lists WHERE type='public'),
'optin_single', (SELECT COUNT(*) FROM lists WHERE optin='single'),
'optin_double', (SELECT COUNT(*) FROM lists WHERE optin='double')
),
'campaigns', JSON_BUILD_OBJECT(
'total', (SELECT COUNT(*) FROM campaigns),
'by_status', (
SELECT JSON_OBJECT_AGG (status, num) FROM
(SELECT status, COUNT(*) AS num FROM campaigns GROUP BY status) r
)
),
'messages', (SELECT SUM(sent) AS messages FROM campaigns)
) AS data;
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_stats_idx ON mat_dashboard_counts (updated_at);
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_charts AS
WITH clicks AS (
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (
WITH viewDates AS (
SELECT TIMEZONE('UTC', created_at)::DATE AS to_date,
TIMEZONE('UTC', created_at)::DATE - INTERVAL '30 DAY' AS from_date
FROM link_clicks ORDER BY id DESC LIMIT 1
)
SELECT COUNT(*) AS count, created_at::DATE as date FROM link_clicks
-- use > between < to force the use of the date index.
WHERE TIMEZONE('UTC', created_at)::DATE BETWEEN (SELECT from_date FROM viewDates) AND (SELECT to_date FROM viewDates)
GROUP by date ORDER BY date
) row
),
views AS (
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (
WITH viewDates AS (
SELECT TIMEZONE('UTC', created_at)::DATE AS to_date,
TIMEZONE('UTC', created_at)::DATE - INTERVAL '30 DAY' AS from_date
FROM campaign_views ORDER BY id DESC LIMIT 1
)
SELECT COUNT(*) AS count, created_at::DATE as date FROM campaign_views
-- use > between < to force the use of the date index.
WHERE TIMEZONE('UTC', created_at)::DATE BETWEEN (SELECT from_date FROM viewDates) AND (SELECT to_date FROM viewDates)
GROUP by date ORDER BY date
) row
)
SELECT NOW() AS updated_at, JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
'campaign_views', COALESCE((SELECT * FROM views), '[]')
) AS data;
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_charts_idx ON mat_dashboard_charts (updated_at);
-- subscriber counts stats for lists
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_list_subscriber_stats AS
SELECT NOW() AS updated_at, lists.id AS list_id, subscriber_lists.status, COUNT(*) AS subscriber_count FROM lists
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
GROUP BY lists.id, subscriber_lists.status
UNION ALL
SELECT NOW() AS updated_at, 0 AS list_id, NULL AS status, COUNT(*) AS subscriber_count FROM subscribers;
CREATE UNIQUE INDEX IF NOT EXISTS mat_list_subscriber_stats_idx ON mat_list_subscriber_stats (list_id, status);
`); err != nil {
return err
}
return nil
}
+144
View File
@@ -0,0 +1,144 @@
package migrations
import (
"encoding/json"
"log"
"os"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
// V4_0_0 performs the DB migrations.
func V4_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_subs_id_status ON subscribers(id, status);`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN
CREATE TYPE user_type AS ENUM ('user', 'api');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
CREATE TYPE role_type AS ENUM ('user', 'list');
END IF;
END$$;
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
type role_type NOT NULL DEFAULT 'user',
parent_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}',
name TEXT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles ON roles (parent_id, list_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_name ON roles (type, name) WHERE name IS NOT NULL;
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_login BOOLEAN NOT NULL DEFAULT false,
password TEXT NULL,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar TEXT NULL,
type user_type NOT NULL DEFAULT 'user',
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
status user_status NOT NULL DEFAULT 'disabled',
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT NOT NULL PRIMARY KEY,
data JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions ON sessions (id, created_at);
`); err != nil {
return err
}
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES('security.oidc', '{"enabled": false, "provider_url": "", "client_id": "", "client_secret": ""}') ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Insert superuser role.
pmRaw, err := fs.Read("/permissions.json")
if err != nil {
lo.Fatalf("error reading permissions file: %v", err)
}
permGroups := []struct {
Group string `json:"group"`
Permissions []string `json:"permissions"`
}{}
if err := json.Unmarshal(pmRaw, &permGroups); err != nil {
lo.Fatalf("error loading permissions file: %v", err)
}
// Create super admin.
var (
user = os.Getenv("EAGLECAST_ADMIN_USER")
password = os.Getenv("EAGLECAST_ADMIN_PASSWORD")
typ = "env"
)
if user != "" {
// If the env vars are set, use those values
if len(user) < 2 || len(password) < 8 {
lo.Fatal("EAGLECAST_ADMIN_USER should be min 3 chars and EAGLECAST_ADMIN_PASSWORD should be min 8 chars")
}
} else if ko.Exists("app.admin_username") {
// Legacy admin/password are set in the config or env var. Use those.
user = ko.String("app.admin_username")
password = ko.String("app.admin_password")
if len(user) < 2 || len(password) < 8 {
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars in the TOML config")
}
typ = "TOML config"
}
if user != "" && password != "" {
lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ)
perms := []string{}
for _, group := range permGroups {
perms = append(perms, group.Permissions...)
}
if _, err := db.Exec(`INSERT INTO roles (type, name, permissions) VALUES('user', 'Super Admin', $1) ON CONFLICT DO NOTHING`, pq.Array(perms)); err != nil {
return err
}
if _, err := db.Exec(`
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, status) VALUES($1, true, CRYPT($2, GEN_SALT('bf')), $3, $4, 'user', 1, 'enabled') ON CONFLICT DO NOTHING;
`, user, password, user+"@eaglecast", user); err != nil {
return err
}
} else {
lo.Printf("no Super Admin user created. Visit webpage to create user.")
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V4_1_0 performs the DB migrations.
func V4_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES('bounce.forwardemail', '{"enabled": false, "key": ""}') ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V5_0_0 performs the DB migrations.
func V5_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
lo.Println("IMPORTANT: this upgrade might take a while if you have a large database. Please be patient ...")
if _, err := db.Exec(`
-- Create a new temp materialized view with the fixed query (removing COUNT(*) that returns 1 for NULLs)
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_list_subscriber_stats_v5_0_0 AS
SELECT NOW() AS updated_at, lists.id AS list_id, subscriber_lists.status, COUNT(subscriber_lists.status) AS subscriber_count FROM lists
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
GROUP BY lists.id, subscriber_lists.status
UNION ALL
SELECT NOW() AS updated_at, 0 AS list_id, NULL AS status, COUNT(id) AS subscriber_count FROM subscribers;
-- Drop the old view and index.
DROP INDEX IF EXISTS mat_list_subscriber_stats_idx;
DROP MATERIALIZED VIEW IF EXISTS mat_list_subscriber_stats;
-- Rename the temp view and create an index.
ALTER MATERIALIZED VIEW mat_list_subscriber_stats_v5_0_0 RENAME TO mat_list_subscriber_stats;
CREATE UNIQUE INDEX IF NOT EXISTS mat_list_subscriber_stats_idx ON mat_list_subscriber_stats (list_id, status);
`); err != nil {
return err
}
// Index of media filename lookup.
if _, err := db.Exec(`
CREATE INDEX IF NOT EXISTS idx_media_filename ON media(provider, filename);
`); err != nil {
return err
}
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('privacy.domain_allowlist', '[]'),
('security.oidc.provider_name', '""')
ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Insert new default super admin permissions.
if _, err := db.Exec(`
UPDATE roles SET permissions = permissions || '{campaigns:get_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:get_all}';
UPDATE roles SET permissions = permissions || '{campaigns:manage_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:manage_all}';
`); err != nil {
return err
}
// Visual editor changes.
if _, err := db.Exec(`
ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'visual';
ALTER TYPE template_type ADD VALUE IF NOT EXISTS 'campaign_visual';
ALTER TABLE templates ADD COLUMN IF NOT EXISTS body_source TEXT NULL;
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS body_source TEXT NULL;
`); err != nil {
return err
}
if _, err := db.Exec(`
ALTER TABLE campaigns DROP CONSTRAINT IF EXISTS campaigns_template_id_fkey,
ADD FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE SET NULL,
ALTER COLUMN template_id DROP DEFAULT;
ALTER TABLE campaigns DROP CONSTRAINT IF EXISTS campaigns_archive_template_id_fkey,
ADD FOREIGN KEY (archive_template_id) REFERENCES templates(id) ON DELETE SET NULL,
ALTER COLUMN archive_template_id DROP DEFAULT;
`); err != nil {
return err
}
// Insert visual campaign template.
visualTpl, err := fs.Get("/static/email-templates/default-visual.tpl")
if err != nil {
lo.Fatalf("error reading default visual template: %v", err)
}
visualSrc, err := fs.Get("/static/email-templates/default-visual.json")
if err != nil {
lo.Fatalf("error reading default visual template json: %v", err)
}
if _, err := db.Exec(`INSERT INTO templates (name, type, subject, body, body_source) VALUES($1, $2, $3, $4, $5)`,
"Sample visual template", "campaign_visual", "", visualTpl.ReadBytes(), visualSrc.ReadBytes()); err != nil {
return err
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V5_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Update OIDC settings to include auto_create_users and default_user_role_id fields if not present
_, err := db.Exec(`
UPDATE settings
SET value = value::JSONB
|| CASE WHEN NOT (value::JSONB ? 'auto_create_users') THEN '{"auto_create_users": false}'::JSONB ELSE '{}'::JSONB END
|| CASE WHEN NOT (value::JSONB ? 'default_user_role_id') THEN '{"default_user_role_id": null}'::JSONB ELSE '{}'::JSONB END
|| CASE WHEN NOT (value::JSONB ? 'default_list_role_id') THEN '{"default_list_role_id": null}'::JSONB ELSE '{}'::JSONB END
WHERE key = 'security.oidc';
`)
if err != nil {
return err
}
// Migrate old captcha settings to new JSON structure.
_, err = db.Exec(`
WITH old AS (
SELECT
COALESCE((SELECT (value#>>'{}')::BOOLEAN FROM settings WHERE key = 'security.enable_captcha'), false) AS enable_captcha,
COALESCE((SELECT value#>>'{}' FROM settings WHERE key = 'security.captcha_key'), '') AS captcha_key,
COALESCE((SELECT value#>>'{}' FROM settings WHERE key = 'security.captcha_secret'), '') AS captcha_secret
)
INSERT INTO settings (key, value, updated_at)
SELECT
'security.captcha',
JSON_BUILD_OBJECT(
'altcha', JSON_BUILD_OBJECT('enabled', false, 'complexity', 300000),
'hcaptcha', JSON_BUILD_OBJECT('enabled', enable_captcha, 'key', captcha_key, 'secret', captcha_secret)
),
NOW()
FROM old
ON CONFLICT (key) DO NOTHING
`)
if err != nil {
return err
}
// Remove old captcha settings.
if _, err = db.Exec(`DELETE FROM settings WHERE key IN ('security.enable_captcha', 'security.captcha_key', 'security.captcha_secret')`); err != nil {
return err
}
// Add maintenance.db setting if not present.
_, err = db.Exec(`
INSERT INTO settings (key, value, updated_at)
VALUES ('maintenance.db', '{"vacuum": false, "vacuum_cron_interval": "0 2 * * *"}', NOW())
ON CONFLICT (key) DO NOTHING
`)
if err != nil {
return err
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V6_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
_, err := db.Exec(`
INSERT INTO settings (key, value, updated_at) VALUES ('security.cors_origins', '[]', NOW()) ON CONFLICT (key) DO NOTHING
`)
if err != nil {
return err
}
// Add 2FA fields to users table.
_, err = db.Exec(`
DO $$ BEGIN
-- Create twofa_type enum if it doesn't exist
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'twofa_type') THEN
CREATE TYPE twofa_type AS ENUM ('none', 'totp');
END IF;
END $$;
ALTER TABLE users ADD COLUMN IF NOT EXISTS twofa_type twofa_type NOT NULL DEFAULT 'none';
ALTER TABLE users ADD COLUMN IF NOT EXISTS twofa_key TEXT NULL;
`)
if err != nil {
return err
}
// Add status field to lists table.
_, err = db.Exec(`
DO $$ BEGIN
-- Create list_status enum if it doesn't exist
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_status') THEN
CREATE TYPE list_status AS ENUM ('active', 'archived');
END IF;
END $$;
ALTER TABLE lists ADD COLUMN IF NOT EXISTS status list_status NOT NULL DEFAULT 'active';
CREATE INDEX IF NOT EXISTS idx_lists_status ON lists(status);
`)
if err != nil {
return err
}
// Add attribs field to campaigns table.
_, err = db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS attribs JSONB NOT NULL DEFAULT '{}'`)
if err != nil {
return err
}
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V6_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
INSERT INTO settings (key, value, updated_at) VALUES ('privacy.disable_tracking', 'false', NOW()) ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES('bounce.lettermint', '{"enabled": false, "key": ""}') ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Drop the old UTC-based date indexes and simply use local time with zone consistent
// with the rest of the schema.
if _, err := db.Exec(`
DROP INDEX IF EXISTS idx_views_date; CREATE INDEX IF NOT EXISTS idx_views_date ON campaign_views(created_at);
DROP INDEX IF EXISTS idx_clicks_date; CREATE INDEX IF NOT EXISTS idx_clicks_date ON link_clicks(created_at);
DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX IF NOT EXISTS idx_bounces_date ON bounces(created_at);
`); err != nil {
return err
}
// Recreate the materialized views to use server local time instead of UTC.
// Create new views first, let them populate, then drop the old ones and rename the new ones.
lo.Println("IMPORTANT: recreating analytics materialized views. This might take a while if you have a large database. Please be patient ...")
if _, err := db.Exec(`
CREATE MATERIALIZED VIEW IF NOT EXISTS mat_dashboard_charts_v6_1_0 AS
WITH clicks AS (
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (
WITH viewDates AS (
SELECT created_at::DATE AS to_date,
created_at::DATE - INTERVAL '30 DAY' AS from_date
FROM link_clicks ORDER BY id DESC LIMIT 1
)
SELECT COUNT(*) AS count, created_at::DATE as date FROM link_clicks
WHERE created_at >= (SELECT from_date FROM viewDates)
AND created_at < (SELECT to_date FROM viewDates) + INTERVAL '1 day'
GROUP by date ORDER BY date
) row
),
views AS (
SELECT JSON_AGG(ROW_TO_JSON(row))
FROM (
WITH viewDates AS (
SELECT created_at::DATE AS to_date,
created_at::DATE - INTERVAL '30 DAY' AS from_date
FROM campaign_views ORDER BY id DESC LIMIT 1
)
SELECT COUNT(*) AS count, created_at::DATE as date FROM campaign_views
WHERE created_at >= (SELECT from_date FROM viewDates)
AND created_at < (SELECT to_date FROM viewDates) + INTERVAL '1 day'
GROUP by date ORDER BY date
) row
)
SELECT NOW() AS updated_at, JSON_BUILD_OBJECT('link_clicks', COALESCE((SELECT * FROM clicks), '[]'),
'campaign_views', COALESCE((SELECT * FROM views), '[]')
) AS data;
DROP INDEX IF EXISTS mat_dashboard_charts_idx;
DROP MATERIALIZED VIEW IF EXISTS mat_dashboard_charts;
ALTER MATERIALIZED VIEW mat_dashboard_charts_v6_1_0 RENAME TO mat_dashboard_charts;
CREATE UNIQUE INDEX IF NOT EXISTS mat_dashboard_charts_idx ON mat_dashboard_charts (updated_at);
`); err != nil {
return err
}
// Grant 'campaigns:send' to all roles that previously had 'campaigns:manage' or 'campaigns:manage_all'
// for backwards compatibility. 'campaigns:send' is a new separate permission.
if _, err := db.Exec(`
UPDATE roles SET permissions = permissions || '{campaigns:send}'
WHERE (permissions @> '{campaigns:manage}' OR permissions @> '{campaigns:manage_all}')
AND NOT permissions @> '{campaigns:send}';
`); err != nil {
return err
}
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
func V6_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
// Add `msg_retry_delay` and `from_addresses` to each SMTP server entry in the `smtp`
// Idempotent: only updates rows where at least one entry is missing the key.
if _, err := db.Exec(`
UPDATE settings
SET value = (
SELECT JSONB_AGG(
JSONB_SET(
JSONB_SET(
v,
'{msg_retry_delay}',
COALESCE(v->'msg_retry_delay', '"10ms"'::JSONB)
),
'{from_addresses}',
COALESCE(v->'from_addresses', '[]'::JSONB)
)
ORDER BY ord
)
FROM JSONB_ARRAY_ELEMENTS(value) WITH ORDINALITY AS t(v, ord)
)
WHERE key = 'smtp'
AND EXISTS (
SELECT 1 FROM JSONB_ARRAY_ELEMENTS(value) AS v
WHERE NOT (v ? 'msg_retry_delay') OR NOT (v ? 'from_addresses')
);
`); err != nil {
return err
}
// Update app language settings that used incorrect locale codes.
if _, err := db.Exec(`
UPDATE settings SET value = langs.new_value
FROM (VALUES
('"cs-cz"'::JSONB, '"cs"'::JSONB),
('"jp"'::JSONB, '"ja"'::JSONB),
('"se"'::JSONB, '"sv"'::JSONB)
) AS langs(old_value, new_value)
WHERE key = 'app.lang' AND value = langs.old_value;
`); err != nil {
return err
}
// Add `bounce.azure` for ACS/Event Grid bounce handling; upsert if the row already exists.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES('bounce.azure', '{"enabled": false, "shared_secret": "", "shared_secret_header": ""}')
ON CONFLICT (key) DO UPDATE
SET value = jsonb_build_object(
'enabled', COALESCE((settings.value->>'enabled')::boolean, false),
'shared_secret', COALESCE(settings.value->>'shared_secret', ''),
'shared_secret_header', COALESCE(settings.value->>'shared_secret_header', '')
);
`); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO settings (key, value) VALUES ('app.show_optin_page', 'true') ON CONFLICT (key) DO NOTHING `); err != nil {
return err
}
// Rename `security.cors_origins` to `security.trusted_urls`.
if _, err := db.Exec(`UPDATE settings SET key = 'security.trusted_urls'
WHERE key = 'security.cors_origins'
AND NOT EXISTS (SELECT 1 FROM settings WHERE key = 'security.trusted_urls')`); err != nil {
return err
}
// Hash existing API tokens. This is idempotent by skipping values that
// already look like lowercase SHA-256 hex digests.
if _, err := db.Exec(`
UPDATE users
SET password = ENCODE(DIGEST(password, 'sha256'), 'hex')
WHERE type = 'api'
AND password IS NOT NULL
AND password != ''
AND password !~ '^[a-f0-9]{64}$';
`); err != nil {
return err
}
return nil
}
+113
View File
@@ -0,0 +1,113 @@
// package notifs is a special singleton, stateful globally accessible package
// that handles sending out arbitrary notifications to the admin and users.
// It's initialized once in the main package and is accessed globally across
// other packages.
package notifs
import (
"bytes"
"html/template"
"log"
"net/textproto"
"regexp"
"strings"
"source.offmarket.win/aleagle/eaglecast/internal/messenger/email"
"source.offmarket.win/aleagle/eaglecast/models"
)
const (
TplImport = "import-status"
TplCampaignStatus = "campaign-status"
TplSubscriberOptin = "subscriber-optin"
TplSubscriberData = "subscriber-data"
TplForgotPassword = "forgot-password"
)
type FuncPush func(msg models.Message) error
type FuncNotif func(toEmails []string, subject, tplName string, data any, headers textproto.MIMEHeader) error
type FuncNotifSystem func(subject, tplName string, data any, headers textproto.MIMEHeader) error
type Opt struct {
FromEmail string
SystemEmails []string
ContentType string
}
type Notifs struct {
em *email.Emailer
lo *log.Logger
opt Opt
}
var (
reTitle = regexp.MustCompile(`(?s)<title\s*data-i18n\s*>(.+?)</title>`)
Tpls *template.Template
no *Notifs
)
// Initialize returns a new Notifs instance.
func Initialize(opt Opt, tpls *template.Template, em *email.Emailer, lo *log.Logger) {
if no != nil {
lo.Fatal("notifs already initialized")
}
Tpls = tpls
no = &Notifs{
opt: opt,
em: em,
lo: lo,
}
}
// NotifySystem sends out an e-mail notification to the admin emails.
func NotifySystem(subject, tplName string, data any, hdr textproto.MIMEHeader) error {
return Notify(no.opt.SystemEmails, subject, tplName, data, hdr)
}
// Notify sends out an e-mail notification.
func Notify(toEmails []string, subject, tplName string, data any, hdr textproto.MIMEHeader) error {
if len(toEmails) == 0 {
return nil
}
var buf bytes.Buffer
if err := Tpls.ExecuteTemplate(&buf, tplName, data); err != nil {
no.lo.Printf("error compiling notification template '%s': %v", tplName, err)
return err
}
body := buf.Bytes()
subject, body = GetTplSubject(subject, body)
m := models.Message{
Messenger: "email",
ContentType: no.opt.ContentType,
From: no.opt.FromEmail,
To: toEmails,
Subject: subject,
Body: body,
Headers: hdr,
}
// Send the message.
if err := no.em.Push(m); err != nil {
no.lo.Printf("error sending admin notification (%s): %v", subject, err)
return err
}
return nil
}
// GetTplSubject extracts any custom i18n subject rendered in the given rendered
// template body. If it's not found, the incoming subject and body are returned.
func GetTplSubject(subject string, body []byte) (string, []byte) {
m := reTitle.FindSubmatch(body)
if len(m) != 2 {
return subject, body
}
return strings.TrimSpace(string(m[1])), reTitle.ReplaceAll(body, []byte(""))
}
+765
View File
@@ -0,0 +1,765 @@
// Package subimporter implements a bulk ZIP/CSV importer of subscribers.
// It implements a simple queue for buffering imports and committing records
// to DB along with ZIP and CSV handling utilities. It is meant to be used as
// a singleton as each Importer instance is stateful, where it keeps track of
// an import in progress. Only one import should happen on a single importer
// instance at a time.
package subimporter
import (
"archive/zip"
"bytes"
"database/sql"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/gofrs/uuid/v5"
"source.offmarket.win/aleagle/eaglecast/internal/i18n"
"source.offmarket.win/aleagle/eaglecast/internal/utils"
"source.offmarket.win/aleagle/eaglecast/models"
"github.com/lib/pq"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const (
// commitBatchSize is the number of inserts to commit in a single SQL transaction.
commitBatchSize = 10000
)
// Various import statuses.
const (
StatusNone = "none"
StatusImporting = "importing"
StatusStopping = "stopping"
StatusFinished = "finished"
StatusFailed = "failed"
ModeSubscribe = "subscribe"
ModeBlocklist = "blocklist"
)
// Importer represents the bulk CSV subscriber import system.
type Importer struct {
opt Options
db *sql.DB
i18n *i18n.I18n
domainBlocklist map[string]struct{}
hasBlocklistWildcards bool
hasBlocklist bool
domainAllowlist map[string]struct{}
hasAllowlistWildcards bool
hasAllowlist bool
stop chan bool
status Status
sync.RWMutex
}
// Options represents import options.
type Options struct {
UpsertStmt *sql.Stmt
BlocklistStmt *sql.Stmt
UpdateListDateStmt *sql.Stmt
PostCB func(subject string, data any) error
DomainBlocklist []string
DomainAllowlist []string
}
// Session represents a single import session.
type Session struct {
im *Importer
subQueue chan SubReq
log *log.Logger
opt SessionOpt
}
// SessionOpt represents the options for an importer session.
type SessionOpt struct {
Filename string `json:"filename"`
Mode string `json:"mode"`
SubStatus string `json:"subscription_status"`
Overwrite bool `json:"overwrite"`
OverwriteUserInfo bool `json:"overwrite_userinfo"`
OverwriteSubStatus bool `json:"overwrite_subscription_status"`
Delim string `json:"delim"`
ListIDs []int `json:"lists"`
}
// Status represents statistics from an ongoing import session.
type Status struct {
Name string `json:"name"`
Total int `json:"total"`
Imported int `json:"imported"`
Status string `json:"status"`
logBuf *bytes.Buffer
}
// SubReq is a wrapper over the Subscriber model.
type SubReq struct {
models.Subscriber
Lists []int `json:"lists"`
ListUUIDs []string `json:"list_uuids"`
PreconfirmSubs bool `json:"preconfirm_subscriptions"`
}
type importStatusTpl struct {
Name string
Status string
Imported int
Total int
}
var (
// ErrIsImporting is thrown when an import request is made while an
// import is already running.
ErrIsImporting = errors.New("import is already running")
csvHeaders = map[string]bool{
"email": true,
"name": true,
"attributes": true}
regexCleanStr = regexp.MustCompile("[[:^ascii:]]")
)
// New returns a new instance of Importer.
func New(opt Options, db *sql.DB, i *i18n.I18n) *Importer {
im := Importer{
opt: opt,
db: db,
i18n: i,
domainBlocklist: make(map[string]struct{}, len(opt.DomainBlocklist)),
domainAllowlist: make(map[string]struct{}, len(opt.DomainAllowlist)),
status: Status{Status: StatusNone, logBuf: bytes.NewBuffer(nil)},
stop: make(chan bool, 1),
}
// Domain blocklist.
mp, hasWildcards := makeDomainMap(opt.DomainBlocklist)
im.domainBlocklist = mp
im.hasBlocklistWildcards = hasWildcards
im.hasBlocklist = len(mp) > 0
// Domain allowlist.
mp, hasWildcards = makeDomainMap(opt.DomainAllowlist)
im.domainAllowlist = mp
im.hasAllowlistWildcards = hasWildcards
im.hasAllowlist = len(mp) > 0
return &im
}
// NewSession returns an new instance of Session. It takes the name
// of the uploaded file, but doesn't do anything with it but retains it for stats.
func (im *Importer) NewSession(opt SessionOpt) (*Session, error) {
if !im.isDone() {
return nil, errors.New("an import is already running")
}
// For API backwards compatibility, if the old 'overwrite'
// field is set, set both overwrite fields to true.
if opt.Overwrite {
opt.OverwriteUserInfo = true
opt.OverwriteSubStatus = true
}
im.Lock()
im.status = Status{Status: StatusImporting,
Name: opt.Filename,
logBuf: bytes.NewBuffer(nil)}
im.Unlock()
s := &Session{
im: im,
log: log.New(im.status.logBuf, "", log.Ldate|log.Ltime|log.Lmicroseconds|log.Lshortfile),
subQueue: make(chan SubReq, commitBatchSize),
opt: opt,
}
s.log.Printf("processing '%s'", opt.Filename)
return s, nil
}
// GetStats returns the global Stats of the importer.
func (im *Importer) GetStats() Status {
im.RLock()
defer im.RUnlock()
return Status{
Name: im.status.Name,
Status: im.status.Status,
Total: im.status.Total,
Imported: im.status.Imported,
}
}
// GetLogs returns the log entries of the last import session.
func (im *Importer) GetLogs() []byte {
im.RLock()
defer im.RUnlock()
if im.status.logBuf == nil {
return []byte{}
}
return im.status.logBuf.Bytes()
}
// setStatus sets the Importer's status.
func (im *Importer) setStatus(status string) {
im.Lock()
im.status.Status = status
im.Unlock()
}
// getStatus get's the Importer's status.
func (im *Importer) getStatus() string {
im.RLock()
status := im.status.Status
im.RUnlock()
return status
}
// isDone returns true if the importer is working (importing|stopping).
func (im *Importer) isDone() bool {
s := true
im.RLock()
if im.getStatus() == StatusImporting || im.getStatus() == StatusStopping {
s = false
}
im.RUnlock()
return s
}
// incrementImportCount sets the Importer's "imported" counter.
func (im *Importer) incrementImportCount(n int) {
im.Lock()
im.status.Imported += n
im.Unlock()
}
// sendNotif sends admin notifications for import completions.
func (im *Importer) sendNotif(status string) error {
var (
s = im.GetStats()
out = importStatusTpl{
Name: s.Name,
Status: status,
Imported: s.Imported,
Total: s.Total,
}
subject = fmt.Sprintf("%s: %s import", cases.Title(language.Und).String(status), s.Name)
)
return im.opt.PostCB(subject, out)
}
// Start is a blocking function that selects on a channel queue until all
// subscriber entries in the import session are imported. It should be
// invoked as a goroutine.
func (s *Session) Start() {
var (
tx *sql.Tx
stmt *sql.Stmt
err error
total = 0
cur = 0
)
listIDs := make([]int, len(s.opt.ListIDs))
copy(listIDs, s.opt.ListIDs)
for sub := range s.subQueue {
if cur == 0 {
// New transaction batch.
tx, err = s.im.db.Begin()
if err != nil {
s.log.Printf("error creating DB transaction: %v", err)
continue
}
if s.opt.Mode == ModeSubscribe {
stmt = tx.Stmt(s.im.opt.UpsertStmt)
} else {
stmt = tx.Stmt(s.im.opt.BlocklistStmt)
}
}
uu, err := uuid.NewV4()
if err != nil {
s.log.Printf("error generating UUID: %v", err)
tx.Rollback()
break
}
if s.opt.Mode == ModeSubscribe {
_, err = stmt.Exec(uu, sub.Email, sub.Name, sub.Attribs, pq.Array(listIDs), s.opt.SubStatus, s.opt.OverwriteUserInfo, s.opt.OverwriteSubStatus)
} else if s.opt.Mode == ModeBlocklist {
_, err = stmt.Exec(uu, sub.Email, sub.Name, sub.Attribs)
}
if err != nil {
s.log.Printf("error executing insert: %v", err)
tx.Rollback()
break
}
cur++
total++
// Batch size is met. Commit.
if cur%commitBatchSize == 0 {
if err := tx.Commit(); err != nil {
tx.Rollback()
s.log.Printf("error committing to DB: %v", err)
} else {
s.im.incrementImportCount(cur)
s.log.Printf("imported %d", total)
}
cur = 0
}
}
// Queue's closed and there's nothing left to commit.
if cur == 0 {
s.im.setStatus(StatusFinished)
s.log.Printf("imported finished")
if _, err := s.im.opt.UpdateListDateStmt.Exec(pq.Array(listIDs)); err != nil {
s.log.Printf("error updating lists date: %v", err)
}
s.im.sendNotif(StatusFinished)
return
}
// Queue's closed and there are records left to commit.
if err := tx.Commit(); err != nil {
tx.Rollback()
s.im.setStatus(StatusFailed)
s.log.Printf("error committing to DB: %v", err)
s.im.sendNotif(StatusFailed)
return
}
s.im.incrementImportCount(cur)
s.im.setStatus(StatusFinished)
s.log.Printf("imported finished")
if _, err := s.im.opt.UpdateListDateStmt.Exec(pq.Array(listIDs)); err != nil {
s.log.Printf("error updating lists date: %v", err)
}
s.im.sendNotif(StatusFinished)
}
// Stop stops an active import session.
func (s *Session) Stop() {
close(s.subQueue)
}
// ExtractZIP takes a ZIP file's path and extracts all .csv files in it to
// a temporary directory, and returns the name of the temp directory and the
// list of extracted .csv files.
func (s *Session) ExtractZIP(srcPath string, maxCSVs int) (string, []string, error) {
if s.im.isDone() {
return "", nil, ErrIsImporting
}
failed := true
defer func() {
if failed {
s.im.setStatus(StatusFailed)
}
}()
z, err := zip.OpenReader(srcPath)
if err != nil {
return "", nil, err
}
defer z.Close()
// Create a temporary directory to extract the files.
dir, err := os.MkdirTemp("", "eaglecast")
if err != nil {
s.log.Printf("error creating temporary directory for extracting ZIP: %v", err)
return "", nil, err
}
files := make([]string, 0, len(z.File))
for _, f := range z.File {
fName := f.FileInfo().Name()
// Skip directories.
if f.FileInfo().IsDir() {
s.log.Printf("skipping directory '%s'", fName)
continue
}
// Skip files without the .csv extension.
if !strings.HasSuffix(strings.ToLower(fName), ".csv") {
s.log.Printf("skipping non .csv file '%s'", fName)
continue
}
// Sanitize the file name to prevent ZIP slip path traversal.
fName = filepath.Base(fName)
s.log.Printf("extracting '%s'", fName)
src, err := f.Open()
if err != nil {
s.log.Printf("error opening '%s' from ZIP: '%v'", fName, err)
return "", nil, err
}
defer src.Close()
out, err := os.OpenFile(dir+"/"+fName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
s.log.Printf("error creating '%s/%s': '%v'", dir, fName, err)
return "", nil, err
}
defer out.Close()
if _, err := io.Copy(out, src); err != nil {
s.log.Printf("error extracting to '%s/%s': '%v'", dir, fName, err)
return "", nil, err
}
s.log.Printf("extracted '%s'", fName)
files = append(files, fName)
if len(files) > maxCSVs {
s.log.Printf("won't extract any more files. Maximum is %d", maxCSVs)
break
}
}
if len(files) == 0 {
s.log.Println("no CSV files found in the ZIP")
return "", nil, errors.New("no CSV files found in the ZIP")
}
failed = false
return dir, files, nil
}
// LoadCSV loads a CSV file and validates and imports the subscriber entries in it.
func (s *Session) LoadCSV(srcPath string, delim rune) error {
if s.im.isDone() {
return ErrIsImporting
}
// Default status is "failed" in case the function
// returns at one of the many possible errors.
failed := true
defer func() {
if failed {
s.im.setStatus(StatusFailed)
}
}()
f, err := os.Open(srcPath)
if err != nil {
return err
}
// Count the total number of lines in the file. This doesn't distinguish
// between "blank" and non "blank" lines, and is only used to derive
// the progress percentage for the frontend.
numLines, err := countLines(f)
if err != nil {
s.log.Printf("error counting lines in '%s': '%v'", srcPath, err)
return err
}
if numLines == 0 {
return errors.New("empty file")
}
// Exclude the header from count.
s.im.Lock()
s.im.status.Total = numLines - 1
s.im.Unlock()
// Rewind, now that we've done a linecount on the same handler.
_, _ = f.Seek(0, 0)
rd := csv.NewReader(f)
rd.Comma = delim
// Read the header.
csvHdr, err := rd.Read()
if err != nil {
s.log.Printf("error reading header from '%s': '%v'", srcPath, err)
return err
}
hdrKeys := s.mapCSVHeaders(csvHdr, csvHeaders)
// email is a required header.
if _, ok := hdrKeys["email"]; !ok {
s.log.Printf("'email' column not found in '%s'", srcPath)
return errors.New("'email' column not found")
}
var (
lnHdr = len(hdrKeys)
i = 0
)
for {
i++
// Check for the stop signal.
select {
case <-s.im.stop:
failed = false
close(s.subQueue)
s.log.Println("stop request received")
return nil
default:
}
cols, err := rd.Read()
if err == io.EOF {
break
} else if err != nil {
if err, ok := err.(*csv.ParseError); ok && err.Err == csv.ErrFieldCount {
s.log.Printf("skipping line %d. %v", i, err)
continue
} else {
s.log.Printf("error reading CSV '%s'", err)
return err
}
}
lnCols := len(cols)
if lnCols < lnHdr {
s.log.Printf("skipping line %d. column count (%d) does not match minimum header count (%d)", i, lnCols, lnHdr)
continue
}
// Iterate the key map and based on the indices mapped earlier,
// form a map of key: csv_value, eg: email: user@user.com.
row := make(map[string]string, lnCols)
for key := range hdrKeys {
row[key] = cols[hdrKeys[key]]
}
sub := SubReq{}
sub.Email = row["email"]
if v, ok := row["name"]; ok {
sub.Name = v
}
sub, err = s.im.ValidateFields(sub)
if err != nil {
s.log.Printf("skipping line %d: %v: %v", i, err, cols)
continue
}
// JSON attributes.
if len(row["attributes"]) > 0 {
var (
attribs models.JSON
b = []byte(row["attributes"])
)
if err := json.Unmarshal(b, &attribs); err != nil {
s.log.Printf("skipping invalid attributes JSON on line %d for '%s': %v", i, sub.Email, err)
} else {
sub.Attribs = attribs
}
}
// Send the subscriber to the queue.
s.subQueue <- sub
}
close(s.subQueue)
failed = false
return nil
}
// Stop sends a signal to stop the existing import.
func (im *Importer) Stop() {
if im.getStatus() != StatusImporting {
im.Lock()
im.status = Status{Status: StatusNone}
im.Unlock()
return
}
select {
case im.stop <- true:
im.setStatus(StatusStopping)
default:
}
}
// SanitizeEmail validates and sanitizes an e-mail string and returns the
// canonical (lowercased, trimmed) address. Domain allowlist/blocklist rules
// are enforced on top of the bare-address validation in utils.SanitizeEmail.
func (im *Importer) SanitizeEmail(email string) (string, error) {
addr, err := utils.SanitizeEmail(email)
if err != nil {
return "", errors.New(im.i18n.T("subscribers.invalidEmail"))
}
// Check if the e-mail's domain is blocklisted. The e-mail domain and blocklist config
// are always lowercase.
if im.hasAllowlist || im.hasBlocklist {
d := strings.Split(addr, "@")
if len(d) != 2 {
return addr, nil
}
domain := d[1]
// If there's an allowlist, check if the domain is in it. Checking blocklist after that is moot.
if im.hasAllowlist {
if !im.checkInList(domain, im.hasAllowlistWildcards, im.domainAllowlist) {
return "", errors.New(im.i18n.T("subscribers.domainBlocklisted"))
}
} else if im.hasBlocklist {
if im.checkInList(domain, im.hasBlocklistWildcards, im.domainBlocklist) {
return "", errors.New(im.i18n.T("subscribers.domainBlocklisted"))
}
}
}
return addr, nil
}
// ValidateFields validates incoming subscriber field values and returns sanitized fields.
func (im *Importer) ValidateFields(s SubReq) (SubReq, error) {
if len(s.Email) > 1000 {
return s, errors.New(im.i18n.T("subscribers.invalidEmail"))
}
em, err := im.SanitizeEmail(s.Email)
if err != nil {
return s, err
}
s.Email = strings.ToLower(em)
// If there's no name, use the name part of the e-mail.
s.Name = strings.TrimSpace(s.Name)
if len(s.Name) == 0 {
name := strings.ToLower(strings.Split(s.Email, "@")[0])
parts := strings.Fields(strings.ReplaceAll(name, ".", " "))
for n, p := range parts {
parts[n] = cases.Title(language.Und).String(p)
}
s.Name = strings.Join(parts, " ")
}
return s, nil
}
// Check the domain against the given map of domains (block/allowlist).
func (im *Importer) checkInList(domain string, hasWildcards bool, mp map[string]struct{}) bool {
// Check the domain as-is.
if _, ok := mp[domain]; ok {
return true
}
// If there are wildcards in the list and the email domain has a subdomain, check that.
if hasWildcards && strings.Count(domain, ".") > 1 {
parts := strings.Split(domain, ".")
// Replace the first part of the subdomain with * and check if that exists in the list.
// Eg: test.mail.example.com => *.mail.example.com
parts[0] = "*"
domain = strings.Join(parts, ".")
if _, ok := mp[domain]; ok {
return true
}
}
return false
}
// mapCSVHeaders takes a list of headers obtained from a CSV file, a map of known headers,
// and returns a new map with each of the headers in the known map mapped by the position (0-n)
// in the given CSV list.
func (s *Session) mapCSVHeaders(csvHdrs []string, knownHdrs map[string]bool) map[string]int {
// Map 0-n column index to the header keys, name: 0, email: 1 etc.
// This is to allow dynamic ordering of columns in th CSV.
hdrKeys := make(map[string]int)
for i, h := range csvHdrs {
// Clean the string of non-ASCII characters (BOM etc.).
h := regexCleanStr.ReplaceAllString(strings.TrimSpace(h), "")
if _, ok := knownHdrs[h]; !ok {
s.log.Printf("ignoring unknown header '%s'", h)
continue
}
hdrKeys[h] = i
}
return hdrKeys
}
// countLines counts the number of line breaks in a file. This does not
// distinguish between "blank" and non "blank" lines.
// Credit: https://stackoverflow.com/a/24563853
func countLines(r io.Reader) (int, error) {
var (
buf = make([]byte, 32*1024)
count = 0
lineSep = byte('\n')
lastByte byte
)
for {
c, err := r.Read(buf)
if c > 0 {
count += bytes.Count(buf[:c], []byte{lineSep})
lastByte = buf[c-1]
}
if err == io.EOF {
break
}
if err != nil {
return count, err
}
}
if lastByte != 0 && lastByte != lineSep {
count++
}
return count, nil
}
func makeDomainMap(domains []string) (map[string]struct{}, bool) {
var (
out = make(map[string]struct{}, len(domains))
hasWildCards = false
)
for _, d := range domains {
out[d] = struct{}{}
// Domains with *. as the subdomain prefix, strip that
// and add the full domain to the blocklist as well.
// eg: *.example.com => example.com
if strings.Contains(d, "*.") {
hasWildCards = true
out[strings.TrimPrefix(d, "*.")] = struct{}{}
}
}
return out, hasWildCards
}
+135
View File
@@ -0,0 +1,135 @@
// Package tmptokens provides a simple in memory store for one time temp tokens with TTL.
// This can be used for creating throwaway tokens for flows like password reset, 2FA verification, etc.
// Tokens are automatically deleted when retrieved or when they expire.
package tmptokens
import (
"errors"
"sync"
"time"
)
const (
// maxTries is the maximum number of verification attempts allowed for a token.
// After this many failed checks, the token is automatically deleted.
maxTries = 15
)
// Token represents a temporary token with TTL and arbitrary data.
type Token struct {
TTL time.Duration
CreatedAt time.Time
Count int
Data any
}
var (
Err = errors.New("token was not found or has expired")
tokens = make(map[string]Token)
mu sync.RWMutex
)
func init() {
// Start periodic cleanup of expired temporary tokens (2FA, password reset).
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
Clean()
}
}()
}
// Set stores a token with the given ID, TTL, and data.
// If a token with the same ID already exists, it will be overwritten silently.
func Set(id string, ttl time.Duration, data any) {
mu.Lock()
defer mu.Unlock()
tokens[id] = Token{
TTL: ttl,
Data: data,
CreatedAt: time.Now(),
}
}
// Check retrieves a token by ID without deleting it.
// An error is returned if the token doesn't exist or has expired.
// Unlike Get(), this method does not consume/delete the token.
// It also increments the check counter and deletes the token if maxTries is exceeded,
// acting as a rate limiter.
func Check(id string) (any, error) {
mu.Lock()
defer mu.Unlock()
token, exists := tokens[id]
if !exists {
return nil, Err
}
// Check if token has expired.
if time.Since(token.CreatedAt) > token.TTL {
delete(tokens, id)
return nil, Err
}
// Increment the rate limit counter.
token.Count++
// Check if max attempts exceeded.
if token.Count > maxTries {
delete(tokens, id)
return nil, Err
}
// Update the token with the new count.
tokens[id] = token
return token.Data, nil
}
// Get retrieves a token by ID and automatically deletes it (after one time use).
// An error is returned if the token doesn't exist or has expired.
func Get(id string) (any, error) {
mu.Lock()
defer mu.Unlock()
token, exists := tokens[id]
if !exists {
return nil, Err
}
// Check if token has expired.
if time.Since(token.CreatedAt) > token.TTL {
delete(tokens, id)
return nil, Err
}
// Delete the token.
delete(tokens, id)
return token.Data, nil
}
// Delete deletes a token by ID.
func Delete(id string) {
mu.Lock()
defer mu.Unlock()
delete(tokens, id)
}
// Clean deletes all expired tokens. This can be called periodically
// to purge unused and expired tokens.
func Clean() {
mu.Lock()
defer mu.Unlock()
now := time.Now()
for id, token := range tokens {
if now.Sub(token.CreatedAt) > token.TTL {
delete(tokens, id)
}
}
}
+78
View File
@@ -0,0 +1,78 @@
package utils
import (
"crypto/rand"
"errors"
"net/mail"
"net/url"
"path"
"strings"
)
// ErrInvalidEmail is returned by SanitizeEmail for malformed input.
var ErrInvalidEmail = errors.New("invalid e-mail address")
// ValidateEmail reports whether s is a correctly formed bare e-mail address
// (no display name component).
func ValidateEmail(s string) bool {
_, err := SanitizeEmail(s)
return err == nil
}
// SanitizeEmail trims, lowercases, and validates s as a bare e-mail address
// (no display name) and returns the canonical form. Returns ErrInvalidEmail
// for anything `mail.ParseAddress` rejects or for input with a display name.
func SanitizeEmail(s string) (string, error) {
s = strings.ToLower(strings.TrimSpace(s))
em, err := mail.ParseAddress(s)
if err != nil || em.Address != s {
return "", ErrInvalidEmail
}
return em.Address, nil
}
// ParseEmailAddress extracts the lowercased bare address from an RFC 5322
// "From"-style header value, accepting both bare addresses ("a@b.com") and
// the display-name form ("Name <a@b.com>"). Returns "" if unparseable.
func ParseEmailAddress(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
em, err := mail.ParseAddress(s)
if err != nil {
return ""
}
return strings.ToLower(em.Address)
}
// GenerateRandomString generates a cryptographically random, alphanumeric string of length n.
func GenerateRandomString(n int) (string, error) {
const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var bytes = make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
for k, v := range bytes {
bytes[k] = dictionary[v%byte(len(dictionary))]
}
return string(bytes), nil
}
// SanitizeURI takes a URL or URI, removes the domain from it, returns only the URI.
// This is used for cleaning "next" redirect URLs/URIs to prevent open redirects.
func SanitizeURI(u string) string {
u = strings.TrimSpace(u)
if u == "" {
return "/"
}
p, err := url.Parse(u)
if err != nil || strings.Contains(p.Path, "..") {
return "/"
}
return path.Clean(p.Path)
}