ghost-listmonk-connector/repopack-output-backend-go.txt
troneras 1fd7dad8e4 # This is a combination of 2 commits.
# This is the 1st commit message:

first commit

# This is the commit message #2:

fixed cache
2024-08-21 02:08:01 +02:00

1634 lines
41 KiB
Text

================================================================
Repopack Output File
================================================================
This file was generated by Repopack on: 2024-08-12T00:02:56.842Z
Purpose:
--------
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
File Format:
------------
The content is organized as follows:
1. This header section
2. Repository structure
3. Multiple file entries, each consisting of:
a. A separator line (================)
b. The file path (File: path/to/file)
c. Another separator line
d. The full contents of the file
e. A blank line
Usage Guidelines:
-----------------
1. This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
2. When processing this file, use the separators and "File:" markers to
distinguish between different files in the repository.
3. Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
Notes:
------
- Some files may have been excluded based on .gitignore rules and Repopack's
configuration.
- Binary files are not included in this packed representation. Please refer to
the Repository Structure section for a complete list of file paths, including
binary files.
For more information about Repopack, visit: https://github.com/yamadashy/repopack
================================================================
Repository Structure
================================================================
handlers/
son_handlers.go
webhook_handler.go
middleware/
auth.go
models/
son.go
services/
database.go
listmonk_client_test.go
listmonk_client.go
son_executor.go
son_storage_test.go
son_storage.go
utils/
config.go
errors.go
logger.go
pretty_print.go
uuid.go
.env.local
docker-compose.yml
Dockerfile
go.mod
main.go
================================================================
Repository Files
================================================================
================
File: handlers/son_handlers.go
================
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/services"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type SonHandler struct {
storage *services.SonStorage
}
type ListmonkHandler struct {
client *services.ListmonkClient
}
func NewSonHandler(storage *services.SonStorage) *SonHandler {
return &SonHandler{storage: storage}
}
func NewListmonkHandler(client *services.ListmonkClient) *ListmonkHandler {
return &ListmonkHandler{client: client}
}
func (h *ListmonkHandler) GetLists(c *gin.Context) {
lists, err := h.client.GetLists()
if err != nil {
utils.ErrorLogger.Errorf("Failed to get lists: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": lists})
}
func (h *ListmonkHandler) GetTemplates(c *gin.Context) {
templates, err := h.client.GetTemplates()
if err != nil {
utils.ErrorLogger.Errorf("Failed to get templates: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": templates})
}
func (h *SonHandler) Create(c *gin.Context) {
var son models.Son
if err := c.ShouldBindJSON(&son); err != nil {
utils.ErrorLogger.Errorf("Invalid Son data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.storage.Create(&son); err != nil {
utils.ErrorLogger.Errorf("Failed to create Son: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
utils.InfoLogger.Infof("Created Son: %s", utils.PrettyPrint(son))
c.JSON(http.StatusCreated, son)
}
func (h *SonHandler) Get(c *gin.Context) {
id := c.Param("id")
son, err := h.storage.Get(id)
if err != nil {
if err == services.ErrSonNotFound {
utils.ErrorLogger.Errorf("Son not found: %s", id)
c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
} else {
utils.ErrorLogger.Errorf("Failed to get Son: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
utils.InfoLogger.Infof("Retrieved Son: %s", utils.PrettyPrint(son))
c.JSON(http.StatusOK, son)
}
func (h *SonHandler) Update(c *gin.Context) {
id := c.Param("id")
var son models.Son
if err := c.ShouldBindJSON(&son); err != nil {
utils.ErrorLogger.Errorf("Invalid Son data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
son.ID = id
if err := h.storage.Update(son); err != nil {
if err == services.ErrSonNotFound {
utils.ErrorLogger.Errorf("Son not found for update: %s", id)
c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
} else {
utils.ErrorLogger.Errorf("Failed to update Son: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
utils.InfoLogger.Infof("Updated Son: %s", utils.PrettyPrint(son))
c.JSON(http.StatusOK, son)
}
func (h *SonHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.storage.Delete(id); err != nil {
if err == services.ErrSonNotFound {
utils.ErrorLogger.Errorf("Son not found for deletion: %s", id)
c.JSON(http.StatusNotFound, gin.H{"error": "Son not found"})
} else {
utils.ErrorLogger.Errorf("Failed to delete Son: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
utils.InfoLogger.Infof("Deleted Son: %s", id)
c.JSON(http.StatusOK, gin.H{"message": "Son deleted successfully"})
}
func (h *SonHandler) List(c *gin.Context) {
sons, err := h.storage.List()
if err != nil {
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
utils.InfoLogger.Infof("Retrieved %d Sons", len(sons))
c.JSON(http.StatusOK, sons)
}
================
File: handlers/webhook_handler.go
================
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/services"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type WebhookHandler struct {
sonStorage *services.SonStorage
executor *services.SonExecutor
}
func NewWebhookHandler(sonStorage *services.SonStorage, executor *services.SonExecutor) *WebhookHandler {
return &WebhookHandler{
sonStorage: sonStorage,
executor: executor,
}
}
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
var webhookData map[string]interface{}
if err := c.ShouldBindJSON(&webhookData); err != nil {
utils.ErrorLogger.Errorf("Invalid webhook data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook data"})
return
}
utils.InfoLogger.Infof("Received webhook: %s", utils.PrettyPrint(webhookData))
triggerType, err := determineTriggerType(webhookData)
if err != nil {
utils.ErrorLogger.Errorf("Unable to determine trigger type: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Unable to determine trigger type"})
return
}
utils.InfoLogger.Infof("Determined trigger type: %s", triggerType)
// Find and execute relevant Sons
sons, err := h.sonStorage.List()
if err != nil {
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list Sons"})
return
}
executedCount := 0
for _, son := range sons {
if son.Trigger == triggerType {
go func(s models.Son) {
utils.InfoLogger.Infof("Executing Son %s for trigger %s", s.ID, triggerType)
h.executor.ExecuteSon(s, webhookData)
}(son)
executedCount++
}
}
utils.InfoLogger.Infof("Executed %d Sons for trigger %s", executedCount, triggerType)
c.JSON(http.StatusOK, gin.H{"message": "Webhook processed successfully", "sons_executed": executedCount})
}
func determineTriggerType(webhookData map[string]interface{}) (models.TriggerType, error) {
if member, ok := webhookData["member"].(map[string]interface{}); ok {
if _, ok := member["current"]; ok {
if _, ok := member["previous"]; ok {
return models.TriggerMemberUpdated, nil
}
return models.TriggerMemberCreated, nil
}
// Assuming member deletion doesn't have 'current' field
return models.TriggerMemberDeleted, nil
}
if post, ok := webhookData["post"].(map[string]interface{}); ok {
if status, ok := post["status"].(string); ok && status == "published" {
return models.TriggerPostPublished, nil
}
if status, ok := post["status"].(string); ok && status == "scheduled" {
return models.TriggerPostScheduled, nil
}
}
if page, ok := webhookData["page"].(map[string]interface{}); ok {
if status, ok := page["status"].(string); ok && status == "published" {
return models.TriggerPagePublished, nil
}
}
return "", utils.NewError("UnknownTriggerType", "Unable to determine trigger type from webhook data")
}
================
File: middleware/auth.go
================
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
func APIKeyAuth(validAPIKey string) gin.HandlerFunc {
return func(c *gin.Context) {
apiKey := c.GetHeader("Authorization")
if apiKey == "" {
apiKey = c.Query("api_key")
}
if apiKey != "Bearer "+validAPIKey {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
c.Abort()
return
}
c.Next()
}
}
================
File: models/son.go
================
package models
import (
"encoding/json"
"errors"
"time"
)
type TriggerType string
type ActionType string
const (
TriggerMemberCreated TriggerType = "member_created"
TriggerMemberDeleted TriggerType = "member_deleted"
TriggerMemberUpdated TriggerType = "member_updated"
TriggerPagePublished TriggerType = "page_published"
TriggerPostPublished TriggerType = "post_published"
TriggerPostScheduled TriggerType = "post_scheduled"
)
const (
ActionSendTransactionalEmail ActionType = "send_transactional_email"
ActionManageSubscriber ActionType = "manage_subscriber"
ActionCreateCampaign ActionType = "create_campaign"
)
type Son struct {
ID string `json:"id"`
Name string `json:"name"`
Trigger TriggerType `json:"trigger"`
Delay Duration `json:"delay"`
Actions []Action `json:"actions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Action struct {
Type ActionType `json:"type"`
Parameters map[string]any `json:"parameters"`
}
// Duration is a custom type to handle time.Duration in JSON
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).Minutes())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = Duration(time.Duration(value) * time.Minute)
return nil
case string:
tmp, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = Duration(tmp)
return nil
default:
return errors.New("invalid duration")
}
}
================
File: services/database.go
================
package services
import (
"database/sql"
"encoding/json"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type Database struct {
db *sql.DB
}
func NewDatabase(dbPath string) (*Database, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
database := &Database{db: db}
if err := database.createTables(); err != nil {
return nil, err
}
return database, nil
}
func (d *Database) createTables() error {
_, err := d.db.Exec(`
CREATE TABLE IF NOT EXISTS sons (
id TEXT PRIMARY KEY,
name TEXT,
trigger TEXT,
delay INTEGER,
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
son_id TEXT,
type TEXT,
parameters TEXT,
FOREIGN KEY (son_id) REFERENCES sons(id)
);
`)
return err
}
func (d *Database) CreateSon(son *models.Son) error {
son.ID = utils.GenerateUUID()
son.CreatedAt = time.Now()
son.UpdatedAt = time.Now()
tx, err := d.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
"INSERT INTO sons (id, name, trigger, delay, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
son.ID, son.Name, son.Trigger, int64(son.Delay), son.CreatedAt, son.UpdatedAt,
)
if err != nil {
return err
}
for _, action := range son.Actions {
parametersJSON, err := json.Marshal(action.Parameters)
if err != nil {
return err
}
_, err = tx.Exec(
"INSERT INTO actions (son_id, type, parameters) VALUES (?, ?, ?)",
son.ID, action.Type, string(parametersJSON),
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (d *Database) GetSon(id string) (*models.Son, error) {
son := &models.Son{}
err := d.db.QueryRow(
"SELECT id, name, trigger, delay, created_at, updated_at FROM sons WHERE id = ?",
id,
).Scan(&son.ID, &son.Name, &son.Trigger, &son.Delay, &son.CreatedAt, &son.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSonNotFound
}
return nil, err
}
rows, err := d.db.Query("SELECT type, parameters FROM actions WHERE son_id = ?", id)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var action models.Action
var parametersJSON string
err := rows.Scan(&action.Type, &parametersJSON)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(parametersJSON), &action.Parameters)
if err != nil {
return nil, err
}
son.Actions = append(son.Actions, action)
}
return son, nil
}
func (d *Database) UpdateSon(son *models.Son) error {
son.UpdatedAt = time.Now()
tx, err := d.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
"UPDATE sons SET name = ?, trigger = ?, delay = ?, updated_at = ? WHERE id = ?",
son.Name, son.Trigger, int64(son.Delay), son.UpdatedAt, son.ID,
)
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM actions WHERE son_id = ?", son.ID)
if err != nil {
return err
}
for _, action := range son.Actions {
parametersJSON, err := json.Marshal(action.Parameters)
if err != nil {
return err
}
_, err = tx.Exec(
"INSERT INTO actions (son_id, type, parameters) VALUES (?, ?, ?)",
son.ID, action.Type, string(parametersJSON),
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (d *Database) DeleteSon(id string) error {
tx, err := d.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM actions WHERE son_id = ?", id)
if err != nil {
return err
}
result, err := tx.Exec("DELETE FROM sons WHERE id = ?", id)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrSonNotFound
}
return tx.Commit()
}
func (d *Database) ListSons() ([]models.Son, error) {
rows, err := d.db.Query("SELECT id, name, trigger, delay, created_at, updated_at FROM sons")
if err != nil {
return nil, err
}
defer rows.Close()
var sons []models.Son
for rows.Next() {
var son models.Son
err := rows.Scan(&son.ID, &son.Name, &son.Trigger, &son.Delay, &son.CreatedAt, &son.UpdatedAt)
if err != nil {
return nil, err
}
sons = append(sons, son)
}
for i, son := range sons {
actionRows, err := d.db.Query("SELECT type, parameters FROM actions WHERE son_id = ?", son.ID)
if err != nil {
return nil, err
}
defer actionRows.Close()
for actionRows.Next() {
var action models.Action
var parametersJSON string
err := actionRows.Scan(&action.Type, &parametersJSON)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(parametersJSON), &action.Parameters)
if err != nil {
return nil, err
}
sons[i].Actions = append(sons[i].Actions, action)
}
}
return sons, nil
}
================
File: services/listmonk_client_test.go
================
package services
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestListmonkClient(t *testing.T) {
// Create a mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tx":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data": true}`))
case "/api/subscribers":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data": {"id": 1}}`))
case "/api/campaigns":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data": {"id": 1}}`))
default:
http.Error(w, "Not found", http.StatusNotFound)
}
}))
defer server.Close()
// Create a client that uses the mock server URL
client := &ListmonkClient{
baseURL: server.URL,
client: server.Client(),
}
// Test SendTransactionalEmail
err := client.SendTransactionalEmail(1, "test@example.com", map[string]interface{}{"name": "Test"})
assert.NoError(t, err)
// Test ManageSubscriber
err = client.ManageSubscriber("test@example.com", "Test User", "enabled", []int{1})
assert.NoError(t, err)
// Test CreateCampaign
err = client.CreateCampaign("Test Campaign", "Test Subject", []int{1}, 1, "2023-01-01T00:00:00Z")
assert.NoError(t, err)
}
================
File: services/listmonk_client.go
================
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type ListmonkList struct {
ID int `json:"id"`
Name string `json:"name"`
}
type ListmonkTemplate struct {
ID int `json:"id"`
Name string `json:"name"`
}
type ListmonkClient struct {
baseURL string
client *http.Client
}
func NewListmonkClient(config *utils.Config) *ListmonkClient {
return &ListmonkClient{
baseURL: config.ListmonkURL,
client: &http.Client{},
}
}
func (c *ListmonkClient) GetLists() ([]ListmonkList, error) {
resp, err := c.client.Get(c.baseURL + "/api/lists?page=1&per_page=100")
if err != nil {
return nil, fmt.Errorf("error fetching lists: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result struct {
Data struct {
Results []ListmonkList `json:"results"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("error decoding response: %w", err)
}
return result.Data.Results, nil
}
func (c *ListmonkClient) GetTemplates() ([]ListmonkTemplate, error) {
resp, err := c.client.Get(c.baseURL + "/api/templates?page=1&per_page=100")
if err != nil {
return nil, fmt.Errorf("error fetching templates: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result struct {
Data []ListmonkTemplate `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("error decoding response: %w", err)
}
return result.Data, nil
}
func (c *ListmonkClient) SendTransactionalEmail(templateID int, subscriberEmail string, data map[string]interface{}) error {
payload := map[string]interface{}{
"subscriber_email": subscriberEmail,
"template_id": templateID,
"data": data,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
return fmt.Errorf("failed to marshal payload: %w", err)
}
resp, err := c.client.Post(c.baseURL+"/api/tx", "application/json", bytes.NewBuffer(jsonPayload))
if err != nil {
utils.ErrorLogger.Errorf("Failed to send transactional email: %v", err)
return fmt.Errorf("failed to send transactional email: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
utils.InfoLogger.Infof("Sent transactional email to %s using template %d", subscriberEmail, templateID)
return nil
}
func (c *ListmonkClient) ManageSubscriber(email string, name string, status string, lists []int) error {
payload := map[string]interface{}{
"email": email,
"name": name,
"status": status,
"lists": lists,
"preconfirm_subscriptions": true,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
return fmt.Errorf("failed to marshal payload: %w", err)
}
resp, err := c.client.Post(c.baseURL+"/api/subscribers", "application/json", bytes.NewBuffer(jsonPayload))
if err != nil {
utils.ErrorLogger.Errorf("Failed to manage subscriber: %v", err)
return fmt.Errorf("failed to manage subscriber: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
utils.InfoLogger.Infof("Managed subscriber %s with status %s", email, status)
return nil
}
func (c *ListmonkClient) CreateCampaign(name string, subject string, lists []int, templateID int, sendAt string) error {
payload := map[string]interface{}{
"name": name,
"subject": subject,
"lists": lists,
"template_id": templateID,
"send_at": sendAt,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
utils.ErrorLogger.Errorf("Failed to marshal payload: %v", err)
return fmt.Errorf("failed to marshal payload: %w", err)
}
resp, err := c.client.Post(c.baseURL+"/api/campaigns", "application/json", bytes.NewBuffer(jsonPayload))
if err != nil {
utils.ErrorLogger.Errorf("Failed to create campaign: %v", err)
return fmt.Errorf("failed to create campaign: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
utils.ErrorLogger.Errorf("Unexpected status code: %d, body: %s", resp.StatusCode, string(body))
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
utils.InfoLogger.Infof("Created campaign %s with subject %s", name, subject)
return nil
}
================
File: services/son_executor.go
================
package services
import (
"fmt"
"time"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type SonExecutor struct {
listmonkClient *ListmonkClient
}
func NewSonExecutor(listmonkClient *ListmonkClient) *SonExecutor {
return &SonExecutor{
listmonkClient: listmonkClient,
}
}
func (e *SonExecutor) ExecuteSon(son models.Son, data map[string]interface{}) {
utils.InfoLogger.Infof("Executing Son: %s", son.ID)
if son.Delay > 0 {
utils.InfoLogger.Infof("Delaying execution of Son %s for %v", son.ID, time.Duration(son.Delay))
time.Sleep(time.Duration(son.Delay))
}
for _, action := range son.Actions {
err := e.executeAction(action, data)
if err != nil {
utils.ErrorLogger.Errorf("Error executing action for Son %s: %v", son.ID, err)
}
}
utils.InfoLogger.Infof("Finished executing Son: %s", son.ID)
}
func (e *SonExecutor) executeAction(action models.Action, data map[string]interface{}) error {
utils.InfoLogger.Infof("Executing action: %s", action.Type)
switch action.Type {
case models.ActionSendTransactionalEmail:
return e.sendTransactionalEmail(action.Parameters, data)
case models.ActionManageSubscriber:
return e.manageSubscriber(action.Parameters, data)
case models.ActionCreateCampaign:
return e.createCampaign(action.Parameters, data)
default:
err := fmt.Errorf("unknown action type: %s", action.Type)
utils.ErrorLogger.Errorf("%v", err)
return err
}
}
func (e *SonExecutor) sendTransactionalEmail(params map[string]interface{}, data map[string]interface{}) error {
templateID := int(params["template_id"].(float64))
subscriberEmail := data["member"].(map[string]interface{})["current"].(map[string]interface{})["email"].(string)
utils.InfoLogger.Infof("Sending transactional email to %s using template %d", subscriberEmail, templateID)
return e.listmonkClient.SendTransactionalEmail(templateID, subscriberEmail, data)
}
func (e *SonExecutor) manageSubscriber(params map[string]interface{}, data map[string]interface{}) error {
email := data["member"].(map[string]interface{})["current"].(map[string]interface{})["email"].(string)
name := data["member"].(map[string]interface{})["current"].(map[string]interface{})["name"].(string)
status := params["status"].(string)
lists := []int{}
for _, listID := range params["lists"].([]interface{}) {
lists = append(lists, int(listID.(float64)))
}
utils.InfoLogger.Infof("Managing subscriber %s with status %s", email, status)
return e.listmonkClient.ManageSubscriber(email, name, status, lists)
}
func (e *SonExecutor) createCampaign(params map[string]interface{}, data map[string]interface{}) error {
name := params["name"].(string)
subject := params["subject"].(string)
lists := []int{}
for _, listID := range params["lists"].([]interface{}) {
lists = append(lists, int(listID.(float64)))
}
templateID := int(params["template_id"].(float64))
sendAt := params["send_at"].(string)
utils.InfoLogger.Infof("Creating campaign %s with subject %s", name, subject)
return e.listmonkClient.CreateCampaign(name, subject, lists, templateID, sendAt)
}
================
File: services/son_storage_test.go
================
package services
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/troneras/ghost-listmonk-connector/models"
)
func TestSonStorage(t *testing.T) {
// Create a temporary database for testing
tempDBPath := "test_ghost_listmonk.db"
storage, err := NewSonStorage(tempDBPath)
assert.NoError(t, err)
defer os.Remove(tempDBPath) // Clean up the temporary database file after tests
// Test Create
son := models.Son{
Name: "Test Son",
Trigger: models.TriggerMemberCreated,
Delay: models.Duration(5 * time.Minute),
Actions: []models.Action{
{
Type: models.ActionSendTransactionalEmail,
Parameters: map[string]interface{}{
"template_id": float64(1), // Use float64 to match JSON unmarshaling behavior
},
},
},
}
err = storage.Create(&son)
assert.NoError(t, err)
assert.NotEmpty(t, son.ID) // Ensure an ID was generated
// Test Get
retrievedSon, err := storage.Get(son.ID)
assert.NoError(t, err)
assert.Equal(t, son.Name, retrievedSon.Name)
assert.Equal(t, son.Trigger, retrievedSon.Trigger)
assert.Equal(t, son.Delay, retrievedSon.Delay)
assert.Len(t, retrievedSon.Actions, 1)
assert.Equal(t, son.Actions[0].Type, retrievedSon.Actions[0].Type)
assert.Equal(t, son.Actions[0].Parameters["template_id"], retrievedSon.Actions[0].Parameters["template_id"])
// Test Update
son.Name = "Updated Test Son"
err = storage.Update(son)
assert.NoError(t, err)
updatedSon, err := storage.Get(son.ID)
assert.NoError(t, err)
assert.Equal(t, "Updated Test Son", updatedSon.Name)
// Test List
sons, err := storage.List()
assert.NoError(t, err)
assert.Len(t, sons, 1)
assert.Equal(t, son.ID, sons[0].ID)
// Test Delete
err = storage.Delete(son.ID)
assert.NoError(t, err)
_, err = storage.Get(son.ID)
assert.Error(t, err)
assert.Equal(t, ErrSonNotFound, err)
// Test error cases
err = storage.Create(&son)
assert.NoError(t, err)
// Attempt to update a non-existent son (should fail)
nonExistentSon := models.Son{ID: "non-existent", Name: "Non-existent Son"}
err = storage.Update(nonExistentSon)
assert.Equal(t, ErrSonNotFound, err)
err = storage.Delete("non-existent")
assert.Equal(t, ErrSonNotFound, err)
}
================
File: services/son_storage.go
================
package services
import (
"database/sql"
"encoding/json"
"errors"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/utils"
)
var (
ErrSonAlreadyExists = errors.New("son with this ID already exists")
ErrSonNotFound = errors.New("son not found")
)
type SonStorage struct {
db *sql.DB
}
func NewSonStorage(dbPath string) (*SonStorage, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
storage := &SonStorage{db: db}
if err := storage.createTables(); err != nil {
return nil, err
}
return storage, nil
}
func (s *SonStorage) createTables() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS sons (
id TEXT PRIMARY KEY,
name TEXT,
trigger TEXT,
delay INTEGER,
actions TEXT,
created_at DATETIME,
updated_at DATETIME
)
`)
return err
}
func (s *SonStorage) Create(son *models.Son) error {
son.ID = utils.GenerateUUID()
son.CreatedAt = time.Now()
son.UpdatedAt = time.Now()
actionsJSON, err := json.Marshal(son.Actions)
if err != nil {
return err
}
_, err = s.db.Exec(
"INSERT INTO sons (id, name, trigger, delay, actions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
son.ID, son.Name, son.Trigger, int64(son.Delay), actionsJSON, son.CreatedAt, son.UpdatedAt,
)
if err != nil {
utils.ErrorLogger.Errorf("Failed to create Son: %v", err)
return err
}
utils.InfoLogger.Infof("Created new Son with ID: %s", son.ID)
return nil
}
func (s *SonStorage) Get(id string) (models.Son, error) {
var son models.Son
var actionsJSON []byte
var delayInt int64
err := s.db.QueryRow(
"SELECT id, name, trigger, delay, actions, created_at, updated_at FROM sons WHERE id = ?",
id,
).Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
utils.ErrorLogger.Errorf("Failed to get Son: %v", ErrSonNotFound)
return models.Son{}, ErrSonNotFound
}
utils.ErrorLogger.Errorf("Failed to get Son: %v", err)
return models.Son{}, err
}
son.Delay = models.Duration(time.Duration(delayInt))
err = json.Unmarshal(actionsJSON, &son.Actions)
if err != nil {
utils.ErrorLogger.Errorf("Failed to unmarshal actions: %v", err)
return models.Son{}, err
}
utils.InfoLogger.Infof("Retrieved Son with ID: %s", id)
return son, nil
}
func (s *SonStorage) Update(son models.Son) error {
son.UpdatedAt = time.Now()
actionsJSON, err := json.Marshal(son.Actions)
if err != nil {
return err
}
result, err := s.db.Exec(
"UPDATE sons SET name = ?, trigger = ?, delay = ?, actions = ?, updated_at = ? WHERE id = ?",
son.Name, son.Trigger, int64(son.Delay), actionsJSON, son.UpdatedAt, son.ID,
)
if err != nil {
utils.ErrorLogger.Errorf("Failed to update Son: %v", err)
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
utils.ErrorLogger.Errorf("Failed to update Son: %v", ErrSonNotFound)
return ErrSonNotFound
}
utils.InfoLogger.Infof("Updated Son with ID: %s", son.ID)
return nil
}
func (s *SonStorage) Delete(id string) error {
result, err := s.db.Exec("DELETE FROM sons WHERE id = ?", id)
if err != nil {
utils.ErrorLogger.Errorf("Failed to delete Son: %v", err)
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
utils.ErrorLogger.Errorf("Failed to delete Son: %v", ErrSonNotFound)
return ErrSonNotFound
}
utils.InfoLogger.Infof("Deleted Son with ID: %s", id)
return nil
}
func (s *SonStorage) List() ([]models.Son, error) {
rows, err := s.db.Query("SELECT id, name, trigger, delay, actions, created_at, updated_at FROM sons")
if err != nil {
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
return nil, err
}
defer rows.Close()
var sons []models.Son
for rows.Next() {
var son models.Son
var actionsJSON []byte
var delayInt int64
err := rows.Scan(&son.ID, &son.Name, &son.Trigger, &delayInt, &actionsJSON, &son.CreatedAt, &son.UpdatedAt)
if err != nil {
utils.ErrorLogger.Errorf("Failed to scan Son: %v", err)
continue
}
son.Delay = models.Duration(time.Duration(delayInt))
err = json.Unmarshal(actionsJSON, &son.Actions)
if err != nil {
utils.ErrorLogger.Errorf("Failed to unmarshal actions: %v", err)
continue
}
sons = append(sons, son)
}
if sons == nil {
sons = []models.Son{} // Ensure we always return an array, even if empty
}
utils.InfoLogger.Infof("Retrieved list of %d Sons", len(sons))
return sons, nil
}
================
File: utils/config.go
================
package utils
import (
"bufio"
"fmt"
"os"
"strings"
)
// Config holds all configuration values
type Config struct {
ListmonkURL string
APIKey string
Port string
// Add other configuration fields as needed
}
// LoadConfig reads configuration from .env.local file and environment variables
func LoadConfig() (*Config, error) {
config := &Config{}
// Read from .env.local file
if err := loadEnvFile(".env.local", config); err != nil {
ErrorLogger.Printf("Error loading .env.local file: %v", err)
// Continue execution, as we'll fall back to environment variables
}
// Override with environment variables if they exist
if envListmonkURL := os.Getenv("LISTMONK_URL"); envListmonkURL != "" {
config.ListmonkURL = envListmonkURL
}
if envAPIKey := os.Getenv("API_KEY"); envAPIKey != "" {
config.APIKey = envAPIKey
}
if envPort := os.Getenv("API_KEY"); envPort != "" {
config.Port = envPort
}
// Validate required fields
if config.ListmonkURL == "" {
return nil, fmt.Errorf("LISTMONK_URL is not set")
}
if config.APIKey == "" {
return nil, fmt.Errorf("API_KEY is not set")
}
if config.Port == "" {
config.Port = "8808"
}
return config, nil
}
func loadEnvFile(filename string, config *Config) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "LISTMONK_URL":
config.ListmonkURL = value
case "API_KEY":
config.APIKey = value
case "PORT":
config.Port = value
// Add other configuration fields as needed
}
}
return scanner.Err()
}
================
File: utils/errors.go
================
package utils
import "fmt"
type CustomError struct {
Code string
Message string
}
func (e *CustomError) Error() string {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func NewError(code, message string) *CustomError {
return &CustomError{
Code: code,
Message: message,
}
}
================
File: utils/logger.go
================
package utils
import (
"log"
"os"
)
type Logger struct {
*log.Logger
}
var (
InfoLogger *Logger
ErrorLogger *Logger
)
func init() {
InfoLogger = &Logger{log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)}
ErrorLogger = &Logger{log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)}
}
func (l *Logger) Infof(format string, v ...interface{}) {
l.Printf(format, v...)
}
func (l *Logger) Errorf(format string, v ...interface{}) {
l.Printf(format, v...)
}
================
File: utils/pretty_print.go
================
package utils
import (
"encoding/json"
)
func PrettyPrint(v interface{}) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return ""
}
return string(b)
}
================
File: utils/uuid.go
================
package utils
import (
"github.com/google/uuid"
)
func GenerateUUID() string {
return uuid.New().String()
}
================
File: .env.local
================
LISTMONK_URL=http://localhost:9000
NEXT_APP_URL=http://localhost:3000
BASIC_AUTH_USERNAME=username
BASIC_AUTH_PASSWORD=password
API_KEY=your-api-key
================
File: docker-compose.yml
================
version: '3'
services:
app:
build: .
ports:
- "8808:8808"
environment:
- PORT=8808
- LISTMONK_URL=http://listmonk:9000
- BASIC_AUTH_USERNAME=admin
- BASIC_AUTH_PASSWORD=password
depends_on:
- listmonk
listmonk:
image: listmonk/listmonk:latest
ports:
- "9000:9000"
environment:
- APP_DB_HOST=db
- APP_DB_USER=listmonk
- APP_DB_PASSWORD=listmonk
depends_on:
- db
db:
image: postgres:13
environment:
- POSTGRES_USER=listmonk
- POSTGRES_PASSWORD=listmonk
- POSTGRES_DB=listmonk
volumes:
- listmonk-data:/var/lib/postgresql/data
volumes:
listmonk-data:
================
File: Dockerfile
================
FROM golang:1.17-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8808
CMD ["./main"]
================
File: go.mod
================
module github.com/troneras/ghost-listmonk-connector
go 1.21.6
require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/stretchr/testify v1.9.0
)
require (
github.com/bytedance/sonic v1.12.1 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.9.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================
File: main.go
================
package main
import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/handlers"
"github.com/troneras/ghost-listmonk-connector/middleware"
"github.com/troneras/ghost-listmonk-connector/services"
"github.com/troneras/ghost-listmonk-connector/utils"
)
func main() {
config, err := utils.LoadConfig()
if err != nil {
utils.ErrorLogger.Fatalf("Failed to load configuration: %v", err)
}
r := gin.Default()
// CORS configuration
corsConfig := cors.DefaultConfig()
corsConfig.AllowOrigins = []string{"http://localhost:3000"} // Add your frontend URL here
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
corsConfig.AllowCredentials = true
r.Use(cors.New(corsConfig))
// Initialize services
sonStorage, err := services.NewSonStorage("ghost_listmonk.db")
if err != nil {
utils.ErrorLogger.Fatalf("Failed to initialize son storage: %v", err)
}
listmonkClient := services.NewListmonkClient(config)
sonExecutor := services.NewSonExecutor(listmonkClient)
sonHandler := handlers.NewSonHandler(sonStorage)
webhookHandler := handlers.NewWebhookHandler(sonStorage, sonExecutor)
listmonkHandler := handlers.NewListmonkHandler(listmonkClient)
// Webhook endpoint (no auth required)
r.POST("/webhook", webhookHandler.HandleWebhook)
// Web UI routes (auth required)
authorized := r.Group("/")
authorized.Use(middleware.APIKeyAuth(config.APIKey))
{
authorized.GET("/", handleHome)
// Son routes
sons := authorized.Group("/sons")
{
sons.POST("", sonHandler.Create)
sons.GET("", sonHandler.List)
sons.GET("/:id", sonHandler.Get)
sons.PUT("/:id", sonHandler.Update)
sons.DELETE("/:id", sonHandler.Delete)
}
authorized.GET("/lists", listmonkHandler.GetLists)
authorized.GET("/templates", listmonkHandler.GetTemplates)
}
utils.InfoLogger.Infof("Server starting on port %s", config.Port)
if err := r.Run(":" + config.Port); err != nil {
utils.ErrorLogger.Fatalf("Failed to start server: %v", err)
}
}
func handleHome(c *gin.Context) {
c.JSON(200, gin.H{"message": "Welcome to the Ghost-Listmonk Connector"})
}