6 files changed, 893 insertions(+), 0 deletions(-)

A => LICENSE
A => client.go
A => client_test.go
A => go.mod
A => params.go
A => params_test.go
A => LICENSE +32 -0
@@ 0,0 1,32 @@ 
+Copyright (c) 2022, Netlandish <hello@netlandish.com>
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or
+without modification, are permitted provided that the
+following conditions are met:
+
+ * Redistributions of source code must retain the above
+   copyright notice, this list of conditions and the
+   following disclaimer.
+
+ * Redistributions in binary form must reproduce the above
+   copyright notice, this list of conditions and the following
+   disclaimer in the documentation and/or other materials
+   provided with the distribution.
+
+ * Neither the name of Peter Sanchez nor the names of its
+   contributors may be used to endorse or promote products
+   derived from this software without specific prior written
+   permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

          
A => client.go +285 -0
@@ 0,0 1,285 @@ 
+package sendy
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"net/url"
+	"strconv"
+)
+
+// ErrAPIResponse APIError generic error type
+var ErrAPIResponse = errors.New("An API error occurred")
+
+const (
+	subscribeEndPoint          = "/subscribe"
+	unsubscribeEndPoint        = "/unsubscribe"
+	subscriptionStatusEndPoint = "/api/subscribers/subscription-status.php"
+	deleteSubscriberEndPoint   = "/api/subscribers/delete.php"
+	subscriberCountEndPoint    = "/api/subscribers/active-subscriber-count.php"
+	createCampaignEndPoint     = "/api/campaigns/create.php"
+	getListEndPoint            = "/api/lists/get-lists.php"
+	getBrandsEndPoint          = "/api/brands/get-brands.php"
+)
+
+// Given an API Http Response read it and return a string
+func decodeRespose(resp *http.Response) (string, error) {
+	defer resp.Body.Close()
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return "", err
+	}
+	return string(body), nil
+}
+
+// Given an API Http Response, read it, parse the json and
+// returns a map of Object{ID: string, Name: string}
+func parseJSON(resp *http.Response, errMsgs []string) (map[string]Object, error) {
+	out, err := decodeRespose(resp)
+	if err != nil {
+		return nil, err
+	}
+	for _, v := range errMsgs {
+		if v == out {
+			return nil, fmt.Errorf("%w: %s", ErrAPIResponse, out)
+		}
+	}
+	var objects map[string]Object
+	err = json.Unmarshal([]byte(out), &objects)
+	if err != nil {
+		return nil, err
+	}
+	return objects, nil
+}
+
+// Object is a generic representation for Brand and List
+type Object struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// httpClient defines an interface for http Clients that must implement
+// the PostForm func
+type httpClient interface {
+	PostForm(url string, data url.Values) (*http.Response, error)
+}
+
+// Client defines the API host, API Key and the http Client to use,
+// by default it is a http.Client instance, but you can change it
+// using the SetDefault method
+type Client struct {
+	host       string
+	apiKey     string
+	httpClient httpClient
+	debug      bool
+}
+
+// SetClient changes the http Client used for sending requests to the API
+// Changing this might be useful for test purposes or using a proxy.
+// By default it is an instance of http.Client. The new client must
+// fulfill the `httpClient` interface that implements a PostForm func
+func (c *Client) SetClient(newClient httpClient) {
+	c.httpClient = newClient
+}
+
+// NewClient return a new Client "instance"
+func NewClient(host, apiKey string, debug bool) *Client {
+	return &Client{
+		host:       host,
+		apiKey:     apiKey,
+		httpClient: &http.Client{},
+		debug:      debug,
+	}
+}
+
+// Call is a simple wrapper for client logic. It is used internally by all the endpoint
+// func and get the request params and return a http.Response or an error.
+// The params are:
+// endpoind: API endpoint
+// params: a url.Values map of the EP params
+// required: a string slice with the required params for this EP
+func (c *Client) Call(endpoint string, params url.Values, required []string) (*http.Response, error) {
+	path, err := url.JoinPath(c.host, endpoint)
+	if err != nil {
+		return nil, err
+	}
+	if err = ValidateParams(params, required); err != nil {
+		return nil, err
+	}
+	params.Set("api_key", c.apiKey)
+	params.Set("boolean", "true")
+	if c.debug {
+		log.Printf("Sending: %s\nParams: %s", path, params)
+	}
+	return c.httpClient.PostForm(path, params)
+}
+
+// Subscribe endpoint. It returns an error or nil if the request was sucessful
+// It reicives as params a SubscribeParam struct where Email and List are required.
+// Optional fields are: Name, Country, IPAddress, Referrer, Silent, GDPR and HP
+func (c *Client) Subscribe(p SusbcribeParams) error {
+	resp, err := c.Call(subscribeEndPoint, p.buildParams(), []string{"email", "list"})
+	if err != nil {
+		return err
+	}
+	out, err := decodeRespose(resp)
+	if err != nil {
+		return err
+	}
+	if out != "1" {
+		return fmt.Errorf("%w: %s", ErrAPIResponse, out)
+	}
+	return nil
+}
+
+// Unsubscribe endpoint. It returns an error or nil if the request was sucessful
+// It reicives as params a UnsusbcribeParams struct where Email and List are required.
+func (c *Client) Unsubscribe(p UnsusbcribeParams) error {
+	resp, err := c.Call(unsubscribeEndPoint, p.buildParams(), []string{"email", "list"})
+	if err != nil {
+		return err
+	}
+	out, err := decodeRespose(resp)
+	if err != nil {
+		return err
+	}
+	if out != "1" {
+		return fmt.Errorf("%w: %s", ErrAPIResponse, out)
+	}
+	return nil
+}
+
+// DeleteSubscriber endpoint. It returns an error or nil if the request was sucessful
+// It reicives as params a ActionParams struct where Email and List are required.
+func (c *Client) DeleteSubscriber(p ActionParams) error {
+	resp, err := c.Call(deleteSubscriberEndPoint, p.buildParams(), []string{"list_id", "email"})
+	if err != nil {
+		return err
+	}
+	out, err := decodeRespose(resp)
+	if err != nil {
+		return err
+	}
+	if out != "1" {
+		return fmt.Errorf("%w: %s", ErrAPIResponse, out)
+	}
+	return nil
+}
+
+// SubscriptionStatus endpoint. If success it returns a string describing the status, otherwise an error.
+// Status might be `Subscribed`, `Unsubscribed`, `Unconfirmed`, `Bounced`, `Soft bounced` or `Complained`
+// It reicives as params a ActionParams struct where Email and List are required.
+func (c *Client) SubscriptionStatus(p ActionParams) (string, error) {
+	resp, err := c.Call(subscriptionStatusEndPoint, p.buildParams(), []string{"list_id", "email"})
+	if err != nil {
+		return "", err
+	}
+	out, err := decodeRespose(resp)
+	if err != nil {
+		return "", err
+	}
+	states := []string{
+		"Subscribed", "Unsubscribed", "Unconfirmed", "Bounced",
+		"Soft bounced", "Complained",
+	}
+
+	for _, v := range states {
+		if out == v {
+			return out, nil
+		}
+	}
+	return "", fmt.Errorf("%w: %s", ErrAPIResponse, out)
+}
+
+// SubscriberCount endpoint. If success it returns a string describing the subscriber number, otherwise an error.
+// It reicives as params a ActionParams struct where List is required
+func (c *Client) SubscriberCount(p ActionParams) (string, error) {
+	resp, err := c.Call(subscriberCountEndPoint, p.buildParams(), []string{"list_id"})
+	if err != nil {
+		return "", err
+	}
+	out, err := decodeRespose(resp)
+	if err != nil {
+		return "", err
+	}
+	_, err = strconv.Atoi(out)
+	if err != nil {
+		return "", fmt.Errorf("%w: %s", ErrAPIResponse, out)
+	}
+	return out, nil
+}
+
+// CreateCampaign endpoint. If succes it returns a string describing the action.
+// It might be `Campaign created`, `Campaign created and now sending` or `Campaign scheduled`.
+// Otherwise it returns an error.
+// It reicives as params a CreateCampaignParams struct. The following fields are required
+// FromName, FromEmail, ReplyTo, Title, Subject, HTMLText, TraskOpens, TrackClicks,
+// ScheduleDateTime, ScheduleTimeZone.
+func (c *Client) CreateCampaign(p CreateCampaignParams) (string, error) {
+	required := []string{
+		"from_name", "from_email", "reply_to", "title",
+		"subject", "html_text", "track_opens", "track_clicks",
+		"schedule_date_time", "schedule_timezone",
+	}
+	resp, err := c.Call(createCampaignEndPoint, p.buildParams(), required)
+	if err != nil {
+		return "", err
+	}
+	out, err := decodeRespose(resp)
+	if err != nil {
+		return "", err
+	}
+
+	states := []string{
+		"Campaign created", "Campaign created and now sending",
+		"Campaign scheduled",
+	}
+
+	for _, v := range states {
+		if out == v {
+			return out, nil
+		}
+	}
+	return "", fmt.Errorf("%w: %s", ErrAPIResponse, out)
+}
+
+// GetBrands endpoint. If succeed returns a map of Object of the form
+// map["brand1"]Object{ID: string, Name: string}
+// Otherwhise it returns an error
+func (c *Client) GetBrands() (map[string]Object, error) {
+	resp, err := c.Call(getBrandsEndPoint, url.Values{}, []string{})
+	if err != nil {
+		return nil, err
+	}
+	errMsgs := []string{
+		"No data passed", "API key not passed",
+		"Invalid API key", "No brands found",
+	}
+
+	return parseJSON(resp, errMsgs)
+}
+
+// GetLists endpoint. If succeed returns a map of Object of the form
+// map["brand1"]Object{ID: string, Name: string}
+// Otherwhise it returns an error
+//
+// Params
+// BrandID: the id of the brand you want to get the list of lists from.
+// IncludeHidden (optional): if you want to retrieve lists that are
+// hidden as well, set this to yes. Default is no.
+func (c *Client) GetLists(p GetListsParams) (map[string]Object, error) {
+	resp, err := c.Call(getListEndPoint, p.buildParams(), []string{"brand_id"})
+	if err != nil {
+		return nil, err
+	}
+
+	errMsgs := []string{
+		"No data passed", "API key not passed",
+		"Invalid API key", "Brand ID not passed",
+		"Brand does not exist", "No lists found",
+	}
+	return parseJSON(resp, errMsgs)
+}

          
A => client_test.go +337 -0
@@ 0,0 1,337 @@ 
+package sendy_test
+
+import (
+	"bytes"
+	"errors"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"testing"
+
+	sendy "hg.code.netlandish.com/~netlandish/sendygo"
+)
+
+var GetPostFormFunc func(url string, data url.Values) (*http.Response, error)
+
+type MockClient struct{}
+
+func (c MockClient) PostForm(url string, data url.Values) (*http.Response, error) {
+	return GetPostFormFunc(url, data)
+}
+
+func createMockResponse(text string) *http.Response {
+	return &http.Response{
+		Status: "200 OK", StatusCode: http.StatusOK,
+		Body: ioutil.NopCloser(bytes.NewBufferString(text)),
+	}
+}
+
+func TestSubscribeSuccess(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("1"), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	err := client.Subscribe(sendy.SusbcribeParams{
+		List:     "xxx",
+		Email:    "yader+test7@netlandish.com",
+		Name:     "Yader Test",
+		Country:  "NI",
+		Referrer: "https://waxbar.co",
+		GDPR:     true,
+	})
+
+	if err != nil {
+		t.Errorf("Unexpected err %v", err)
+	}
+}
+
+func TestSubscribeError(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("Already subscribed."), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	err := client.Subscribe(sendy.SusbcribeParams{
+		List:     "xxx",
+		Email:    "yader+test7@netlandish.com",
+		Name:     "Yader Test",
+		Country:  "NI",
+		Referrer: "https://waxbar.co",
+		GDPR:     true,
+	})
+
+	if !errors.Is(err, sendy.ErrAPIResponse) {
+		t.Errorf("Expected %v, got %v", sendy.ErrAPIResponse, err)
+	}
+}
+
+func TestUnsubscribeSuccess(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("1"), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	err := client.Unsubscribe(sendy.UnsusbcribeParams{
+		List:  "xxx",
+		Email: "yader+test7@netlandish.com",
+	})
+
+	if err != nil {
+		t.Errorf("Unexpected err %v", err)
+	}
+}
+
+func TestUnsubscribeError(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("Invalid email address."), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	err := client.Unsubscribe(sendy.UnsusbcribeParams{
+		List:  "xxx",
+		Email: "yader+test7@netlandish.com",
+	})
+
+	if !errors.Is(err, sendy.ErrAPIResponse) {
+		t.Errorf("Expected %v, got %v", sendy.ErrAPIResponse, err)
+	}
+}
+
+func TestDeleteSubscriberSuccess(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("1"), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	err := client.DeleteSubscriber(sendy.ActionParams{
+		List:  "xxx",
+		Email: "yader+test7@netlandish.com",
+	})
+	if err != nil {
+		t.Errorf("Unexpected err %v", err)
+	}
+}
+
+func TestDeleteSubscriberError(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("Subscriber does not exist"), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	err := client.DeleteSubscriber(sendy.ActionParams{
+		List:  "xxx",
+		Email: "yader+test7@netlandish.com",
+	})
+	if !errors.Is(err, sendy.ErrAPIResponse) {
+		t.Errorf("Expected %v, got %v", sendy.ErrAPIResponse, err)
+	}
+}
+
+func TestSubscriptionStatusSuccess(t *testing.T) {
+	expected := "Subscribed"
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse(expected), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	out, err := client.SubscriptionStatus(sendy.ActionParams{
+		List:  "xxx",
+		Email: "yader+test7@netlandish.com",
+	})
+	if err != nil {
+		t.Errorf("Unexpected err %v", err)
+	}
+
+	if out != expected {
+		t.Errorf("Expected %s, got %s", expected, out)
+	}
+}
+
+func TestSubscriptionStatusError(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("No data passed"), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	_, err := client.SubscriptionStatus(sendy.ActionParams{
+		List:  "xxx",
+		Email: "yader+test7@netlandish.com",
+	})
+	if !errors.Is(err, sendy.ErrAPIResponse) {
+		t.Errorf("Expected %v, got %v", sendy.ErrAPIResponse, err)
+	}
+}
+
+func TestSubscriberCountSuccess(t *testing.T) {
+	expected := "100"
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse(expected), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	out, err := client.SubscriberCount(sendy.ActionParams{
+		List: "xxx",
+	})
+	if err != nil {
+		t.Errorf("Unexpected err %v", err)
+	}
+
+	if out != expected {
+		t.Errorf("Expected %s, got %s", expected, out)
+	}
+}
+
+func TestSubscriberCountError(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("Invalid API key"), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+
+	_, err := client.SubscriberCount(sendy.ActionParams{
+		List: "xxx",
+	})
+	if !errors.Is(err, sendy.ErrAPIResponse) {
+		t.Errorf("Expected %v, got %v", sendy.ErrAPIResponse, err)
+	}
+}
+
+func TestCreateCampaignSuccess(t *testing.T) {
+	expected := "Campaign scheduled"
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse(expected), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+	out, err := client.CreateCampaign(sendy.CreateCampaignParams{
+		FromName:         "Test Campaign 2",
+		FromEmail:        "yader+test10@netlandish.com",
+		ReplyTo:          "yader+test10@netlandish.com",
+		Title:            "Email Title",
+		Subject:          "Email Subject",
+		PlainText:        "Plain text for the email",
+		HTMLText:         "<p>Html format for the email</p>",
+		ListIDS:          "xxxx",
+		QueryString:      "foo",
+		SendCampaign:     1,
+		TrackOpens:       1,
+		TrackClicks:      1,
+		ScheduleDateTime: "June 15, 2023 6:05pm",
+		ScheduleTimeZone: "America/Managua",
+	})
+	if err != nil {
+		t.Errorf("Unexpected err %v", err)
+	}
+
+	if out != expected {
+		t.Errorf("Expected %s, got %s", expected, out)
+	}
+}
+
+func TestCreateCampaignError(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("Unable to create campaign"), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+	_, err := client.CreateCampaign(sendy.CreateCampaignParams{
+		FromName:         "Test Campaign 2",
+		FromEmail:        "yader+test10@netlandish.com",
+		ReplyTo:          "yader+test10@netlandish.com",
+		Title:            "Email Title",
+		Subject:          "Email Subject",
+		PlainText:        "Plain text for the email",
+		HTMLText:         "<p>Html format for the email</p>",
+		ListIDS:          "xxxx",
+		QueryString:      "foo",
+		SendCampaign:     1,
+		TrackOpens:       1,
+		TrackClicks:      1,
+		ScheduleDateTime: "June 15, 2023 6:05pm",
+		ScheduleTimeZone: "America/Managua",
+	})
+	if !errors.Is(err, sendy.ErrAPIResponse) {
+		t.Errorf("Expected %v, got %v", sendy.ErrAPIResponse, err)
+	}
+}
+
+func TestGetBrands(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		out := "{\"brand1\": {\"id\": \"4\", \"name\": \"Netlandish Inc.\"}, \"brand2\": {\"id\": \"2\", \"name\": \"Netlandish 2\"}}"
+		return createMockResponse(out), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+	outMap, err := client.GetBrands()
+	if err != nil {
+		t.Errorf("Unexpected err %v", err)
+	}
+
+	if _, ok := outMap["brand1"]; !ok {
+		t.Errorf("brand1 is expected in the parsed map")
+	}
+
+	if outMap["brand1"].ID != "4" || outMap["brand1"].Name != "Netlandish Inc." {
+		t.Errorf("A Brand struct with ID: \"4\" and Name: \"Netlandish Inc.\" is expected")
+	}
+
+}
+
+func TestGetBrandsError(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("No brands found"), nil
+	}
+
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+	_, err := client.GetBrands()
+
+	if !errors.Is(err, sendy.ErrAPIResponse) {
+		t.Errorf("Expected %v, got %v", sendy.ErrAPIResponse, err)
+	}
+}
+
+func TestGetLists(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		out := "{\"list1\": {\"id\": \"xyz\", \"name\": \"Netlandish Inc.\"}}"
+		return createMockResponse(out), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+	outMap, err := client.GetLists(sendy.GetListsParams{BrandID: "12"})
+	if err != nil {
+		t.Errorf("Unexpected err %v", err)
+	}
+
+	if _, ok := outMap["list1"]; !ok {
+		t.Errorf("list1 is expected in the parsed map")
+	}
+
+	if outMap["list1"].ID != "xyz" || outMap["list1"].Name != "Netlandish Inc." {
+		t.Errorf("A List struct with ID: \"xyz\" and Name: \"Netlandish Inc.\" is expected")
+	}
+
+}
+
+func TestGetListsError(t *testing.T) {
+	GetPostFormFunc = func(url string, data url.Values) (*http.Response, error) {
+		return createMockResponse("No lists found"), nil
+	}
+	client := sendy.NewClient("", "", false)
+	client.SetClient(MockClient{})
+	_, err := client.GetLists(sendy.GetListsParams{BrandID: "12"})
+
+	if !errors.Is(err, sendy.ErrAPIResponse) {
+		t.Errorf("Expected %v, got %v", sendy.ErrAPIResponse, err)
+	}
+}

          
A => go.mod +3 -0
@@ 0,0 1,3 @@ 
+module hg.code.netlandish.com/~netlandish/sendygo
+
+go 1.18

          
A => params.go +191 -0
@@ 0,0 1,191 @@ 
+package sendy
+
+import (
+	"errors"
+	"net/url"
+	"strconv"
+	"strings"
+)
+
+// ErrParamsMissing is raised when the required params are missing
+var ErrParamsMissing = errors.New("required params missing")
+
+// CleanParams strips out empty parameters
+func CleanParams(values url.Values) {
+	for k, v := range values {
+		// if the Values only have one element and that
+		// element is an empty string
+		if strings.TrimSpace(v[0]) == "" && len(v) == 1 {
+			values.Del(k)
+		}
+	}
+}
+
+// ValidateParams validates required params
+func ValidateParams(values url.Values, required []string) error {
+	for _, v := range required {
+		if !values.Has(v) {
+			return ErrParamsMissing
+		}
+	}
+	return nil
+
+}
+
+// SusbcribeParams ...
+//
+// Email: user's email
+// List: the list id you want to subscribe a user to
+// Name: user's name (optional)
+// Country: user's 2 letter country code (optional)
+// IPAddress: user's IP address (optional)
+// Referrer: the URL where the user signed up from (optional)
+// GDPR: if you're signing up EU users in a GDPR compliant manner, set this to "true" (optional)
+// Silent: set to true if your list is 'Double opt-in' but you want to bypass that
+// and signup the user to the list as 'Single Opt-in instead' (optional)
+// HP: include this 'honeypot' field to prevent spambots from signing up via this API call. (optional)
+type SusbcribeParams struct {
+	List      string
+	Email     string
+	Name      string
+	Country   string
+	IPAddress string
+	Referrer  string
+	Silent    bool
+	GDPR      bool
+	HP        string
+}
+
+func (s SusbcribeParams) buildParams() url.Values {
+	data := url.Values{}
+	data.Set("list", s.List)
+	data.Set("email", s.Email)
+	data.Set("name", s.Name)
+	data.Set("country", s.Country)
+	data.Set("ipaddress", s.IPAddress)
+	data.Set("silent", strconv.FormatBool(s.Silent))
+	data.Set("gdpr", strconv.FormatBool(s.GDPR))
+	data.Set("hp", s.HP)
+	CleanParams(data)
+	return data
+}
+
+// UnsusbcribeParams ...
+//
+// Email: user's email
+// List: the list id you want to subscribe a user to
+type UnsusbcribeParams struct {
+	List  string
+	Email string
+}
+
+func (s UnsusbcribeParams) buildParams() url.Values {
+	data := url.Values{}
+	data.Set("list", s.List)
+	data.Set("email", s.Email)
+	CleanParams(data)
+	return data
+}
+
+// ActionParams is aimed to perform actions over already
+// registered email like subscription_status, delete and subscriber_count
+//
+// Email: user's email
+// List: the list id you want to subscribe a user to
+type ActionParams struct {
+	List  string
+	Email string
+}
+
+func (s ActionParams) buildParams() url.Values {
+	data := url.Values{}
+	data.Set("list_id", s.List)
+	data.Set("email", s.Email)
+	CleanParams(data)
+	return data
+}
+
+// CreateCampaignParams ...
+//
+// FormName: the 'From name' of your campaign
+// FromEmail: the 'From email' of your campaign
+// ReplyTo: the 'Reply to' of your campaign
+// Title: the 'Title' of your campaign
+// Subject: the 'Subject' of your campaign
+// PlainText: the 'Plain text version' of your campaign (optional)
+// HTMLText: the 'HTML version' of your campaign
+// ListIDS: Required only if you set SendCampaign to 1 and no SegmentIDS are passed in.
+// List IDs should be single or comma-separated.
+// SegmentIDS: Required only if you set send_campaign to 1 and no list_ids are passed in.
+// Segment IDs should be single or comma-separated.
+// ExcludeList: ids Lists to exclude from your campaign. List IDs should be single or comma-separated.(optional)
+// ExcludeSegmentsIDS: Segments to exclude from your campaign. Segment IDs should be single or comma-separated. (optional)
+// BrandID: Required only if you are creating a 'Draft' campaign
+// QueryString: eg. Google Analytics tags (optional)
+// TrackOpens: Set to 0 to disable, 1 to enable and 2 for anonymous opens tracking.
+// TrackClicks: Set to 0 to disable, 1 to enable and 2 for anonymous clicks tracking.
+// SendCampaign: Set to 1 if you want to send the campaign as well and not just create a draft. Default is 0.
+// ScheduleDateTime: Campaign will be scheduled if a valid date/time is passed. Date/time format. eg. June 15, 2021 6:05pm.
+// ScheduleTimeZone: Eg. 'America/New_York'.
+type CreateCampaignParams struct {
+	FromName           string
+	FromEmail          string
+	ReplyTo            string
+	Title              string
+	Subject            string
+	PlainText          string
+	HTMLText           string
+	ListIDS            string
+	BrandID            string
+	QueryString        string
+	SendCampaign       int64
+	SegmentIDS         string
+	ExcludeListIDS     string
+	ExcludeSegmentsIDS string
+	TrackOpens         int64
+	TrackClicks        int64
+	ScheduleDateTime   string
+	ScheduleTimeZone   string
+}
+
+func (s CreateCampaignParams) buildParams() url.Values {
+	data := url.Values{}
+	data.Set("from_name", s.FromName)
+	data.Set("from_email", s.FromEmail)
+	data.Set("reply_to", s.ReplyTo)
+	data.Set("title", s.Title)
+	data.Set("subject", s.Subject)
+	data.Set("plain_text", s.PlainText)
+	data.Set("html_text", s.HTMLText)
+	data.Set("list_ids", s.ListIDS)
+	data.Set("brand_id", s.BrandID)
+	data.Set("query_string", s.QueryString)
+	data.Set("send_campaign", strconv.Itoa(int(s.SendCampaign)))
+	data.Set("segment_ids", s.SegmentIDS)
+	data.Set("exclude_list_ids", s.ExcludeListIDS)
+	data.Set("exclude_segments_ids", s.ExcludeSegmentsIDS)
+	data.Set("track_opens", strconv.Itoa(int(s.TrackOpens)))
+	data.Set("track_clicks", strconv.Itoa(int(s.TrackClicks)))
+	data.Set("schedule_date_time", s.ScheduleDateTime)
+	data.Set("schedule_timezone", s.ScheduleTimeZone)
+	CleanParams(data)
+	return data
+}
+
+// GetListsParams ...
+//
+// BrandID: the id of the brand you want to get the list of lists from.
+// IncludeHidden: if you want to retrieve lists that are hidden as well, set this to yes. Default is no.
+type GetListsParams struct {
+	BrandID       string
+	IncludeHidden string
+}
+
+func (s GetListsParams) buildParams() url.Values {
+	data := url.Values{}
+	data.Set("brand_id", s.BrandID)
+	if s.IncludeHidden != "" {
+		data.Set("include_hidden", s.IncludeHidden)
+	}
+	return data
+}

          
A => params_test.go +45 -0
@@ 0,0 1,45 @@ 
+package sendy_test
+
+import (
+	"errors"
+	"net/url"
+	"testing"
+
+	sendy "hg.code.netlandish.com/~netlandish/sendygo"
+)
+
+func TestCleanParams(t *testing.T) {
+	values := url.Values{}
+	values.Set("email", "yader@netlandish.com")
+	values.Set("list", "one, two")
+	values.Set("name", "")
+
+	expected := 2
+	sendy.CleanParams(values)
+
+	if len(values) != expected {
+		t.Errorf("Expected %d, got %d", expected, len(values))
+	}
+
+}
+
+func TestValidateParams(t *testing.T) {
+	values := url.Values{}
+	values.Set("email", "yader@netlandish.com")
+
+	err := sendy.ValidateParams(values, []string{"email"})
+	if err != nil {
+		t.Errorf("Expected no error, got %v", err)
+	}
+
+}
+
+func TestValidateParamsError(t *testing.T) {
+	values := url.Values{}
+	values.Set("email", "yader@netlandish.com")
+	err := sendy.ValidateParams(values, []string{"email", "list"})
+	if !errors.Is(err, sendy.ErrParamsMissing) {
+		t.Errorf("Expected ErrParamsMissing error, got %v", err)
+	}
+
+}