================================================================ 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"}) }