# This is the 1st commit message: first commit # This is the commit message #2: fixed cache
1634 lines
41 KiB
Text
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, ¶metersJSON)
|
|
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, ¶metersJSON)
|
|
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"})
|
|
}
|