From b82c3333a49c6f2b287154f38161ec584be02186 Mon Sep 17 00:00:00 2001 From: Lorenc326 Date: Wed, 8 Jul 2026 20:29:02 +0300 Subject: [PATCH] Contacts and contact events resources --- README.md | 5 ++ client.go | 7 +++ contact_events.go | 35 ++++++++++++ contact_events_test.go | 29 ++++++++++ contacts.go | 98 +++++++++++++++++++++++++++++++++ contacts_test.go | 96 ++++++++++++++++++++++++++++++++ examples/contact-events/main.go | 36 ++++++++++++ examples/contacts/main.go | 52 +++++++++++++++++ 8 files changed, 358 insertions(+) create mode 100644 contact_events.go create mode 100644 contact_events_test.go create mode 100644 contacts.go create mode 100644 contacts_test.go create mode 100644 examples/contact-events/main.go create mode 100644 examples/contacts/main.go diff --git a/README.md b/README.md index 99bbaec..a95ff9d 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,11 @@ Account & organization management: - Account accesses — list & remove user/invite/token access — [`examples/account-accesses`](examples/account-accesses) - Permissions — list resources & bulk-update access permissions — [`examples/permissions`](examples/permissions) +Contact management: + +- Contacts — create, get, update (upsert) & delete by ID or email — [`examples/contacts`](examples/contacts) +- Contact events — record custom events for a contact — [`examples/contact-events`](examples/contact-events) + ## Errors Non-2xx responses decode into typed errors that work with `errors.As`: diff --git a/client.go b/client.go index d59cadb..14caa13 100644 --- a/client.go +++ b/client.go @@ -85,6 +85,11 @@ type Client struct { AccountAccesses *AccountAccessesService // Permissions lists account resources and bulk-updates access permissions. Permissions *PermissionsService + + // Contacts manages email marketing contacts. + Contacts *ContactsService + // ContactEvents records custom events for contacts. + ContactEvents *ContactEventsService } // Ptr returns a pointer to v, for setting optional pointer request fields such @@ -142,6 +147,8 @@ func NewClient(token string, opts ...Option) (*Client, error) { c.Accounts = &AccountsService{client: c} c.AccountAccesses = &AccountAccessesService{client: c} c.Permissions = &PermissionsService{client: c} + c.Contacts = &ContactsService{client: c} + c.ContactEvents = &ContactEventsService{client: c} return c, nil } diff --git a/contact_events.go b/contact_events.go new file mode 100644 index 0000000..73127ee --- /dev/null +++ b/contact_events.go @@ -0,0 +1,35 @@ +package mailtrap + +import ( + "context" + "net/http" +) + +// ContactEventsService records custom events for a contact. +type ContactEventsService struct { + client *Client +} + +// ContactEvent is a custom event recorded against a contact. +type ContactEvent struct { + ContactID string `json:"contact_id"` + ContactEmail string `json:"contact_email"` + Name string `json:"name"` + // Params maps event parameter names to scalar values. + Params map[string]any `json:"params,omitempty"` +} + +// CreateContactEventRequest is the payload for recording an event. Name is +// required (max 255 characters). +type CreateContactEventRequest struct { + Name string `json:"name"` + Params map[string]any `json:"params,omitempty"` +} + +// Create records an event for the contact identified by UUID or email. +func (s *ContactEventsService) Create(ctx context.Context, identifier string, req *CreateContactEventRequest) (*ContactEvent, *Response, error) { + path := contactPath(identifier) + "/events" + event := new(ContactEvent) + resp, err := s.client.do(ctx, HostGeneral, http.MethodPost, path, nil, req, event) + return event, resp, err +} diff --git a/contact_events_test.go b/contact_events_test.go new file mode 100644 index 0000000..6009268 --- /dev/null +++ b/contact_events_test.go @@ -0,0 +1,29 @@ +package mailtrap_test + +import ( + "context" + "net/http" + "testing" + + "github.com/mailtrap/mailtrap-go" +) + +func TestContactEvents_Create(t *testing.T) { + mux, client := setup(t) + mux.HandleFunc("POST /api/contacts/018dd5e3/events", func(w http.ResponseWriter, r *http.Request) { + wantJSONBody(t, r, `{"name":"UserLogin","params":{"user_id":101,"is_active":true}}`) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"contact_id":"018dd5e3","contact_email":"john@example.com","name":"UserLogin","params":{"user_id":101}}`)) + }) + + event, _, err := client.ContactEvents.Create(context.Background(), "018dd5e3", &mailtrap.CreateContactEventRequest{ + Name: "UserLogin", + Params: map[string]any{"user_id": 101, "is_active": true}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if event.ContactEmail != "john@example.com" || event.Name != "UserLogin" { + t.Errorf("event = %+v", event) + } +} diff --git a/contacts.go b/contacts.go new file mode 100644 index 0000000..ea8a825 --- /dev/null +++ b/contacts.go @@ -0,0 +1,98 @@ +package mailtrap + +import ( + "context" + "net/http" + "net/url" +) + +// ContactsService manages email marketing contacts. +type ContactsService struct { + client *Client +} + +// Contact subscription statuses for Contact.Status. +const ( + ContactStatusSubscribed = "subscribed" + ContactStatusUnsubscribed = "unsubscribed" +) + +// Contact is an email marketing contact. +type Contact struct { + // ID is the contact's UUID. + ID string `json:"id"` + Email string `json:"email"` + // Fields maps custom field merge tags to scalar values. + Fields map[string]any `json:"fields,omitempty"` + ListIDs []int64 `json:"list_ids,omitempty"` + // Status is the subscription status. + Status string `json:"status,omitempty"` + // CreatedAt and UpdatedAt are Unix timestamps in milliseconds. + CreatedAt int64 `json:"created_at,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` +} + +// CreateContactRequest is the payload for creating a contact. Email is required. +type CreateContactRequest struct { + Email string `json:"email"` + Fields map[string]any `json:"fields,omitempty"` + ListIDs []int64 `json:"list_ids,omitempty"` +} + +// UpdateContactRequest is the payload for updating (upserting) a contact. Email +// is required; ListIDsIncluded and ListIDsExcluded add and remove list +// memberships. +type UpdateContactRequest struct { + Email string `json:"email"` + Fields map[string]any `json:"fields,omitempty"` + ListIDsIncluded []int64 `json:"list_ids_included,omitempty"` + ListIDsExcluded []int64 `json:"list_ids_excluded,omitempty"` + Unsubscribed *bool `json:"unsubscribed,omitempty"` +} + +// ContactUpsert is the result of Update: the contact plus whether it was +// created or updated. +type ContactUpsert struct { + // Action is "created" or "updated". + Action string `json:"action"` + Contact *Contact `json:"data"` +} + +// Create adds a contact. +func (s *ContactsService) Create(ctx context.Context, req *CreateContactRequest) (*Contact, *Response, error) { + var wrapper struct { + Data *Contact `json:"data"` + } + body := map[string]any{"contact": req} + resp, err := s.client.do(ctx, HostGeneral, http.MethodPost, "/api/contacts", nil, body, &wrapper) + return wrapper.Data, resp, err +} + +// Get returns a contact by UUID or email. +func (s *ContactsService) Get(ctx context.Context, identifier string) (*Contact, *Response, error) { + var wrapper struct { + Data *Contact `json:"data"` + } + resp, err := s.client.do(ctx, HostGeneral, http.MethodGet, contactPath(identifier), nil, nil, &wrapper) + return wrapper.Data, resp, err +} + +// Update creates or updates (upserts) the contact identified by UUID or email, +// reporting which action was taken. +func (s *ContactsService) Update(ctx context.Context, identifier string, req *UpdateContactRequest) (*ContactUpsert, *Response, error) { + upsert := new(ContactUpsert) + body := map[string]any{"contact": req} + resp, err := s.client.do(ctx, HostGeneral, http.MethodPatch, contactPath(identifier), nil, body, upsert) + return upsert, resp, err +} + +// Delete removes a contact by UUID or email. +func (s *ContactsService) Delete(ctx context.Context, identifier string) (*Response, error) { + return s.client.do(ctx, HostGeneral, http.MethodDelete, contactPath(identifier), nil, nil, nil) +} + +// contactPath builds the path for a contact identifier (UUID or email), +// escaping it so an email address can be passed unencoded. +func contactPath(identifier string) string { + return "/api/contacts/" + url.PathEscape(identifier) +} diff --git a/contacts_test.go b/contacts_test.go new file mode 100644 index 0000000..6e0d824 --- /dev/null +++ b/contacts_test.go @@ -0,0 +1,96 @@ +package mailtrap_test + +import ( + "context" + "net/http" + "testing" + + "github.com/mailtrap/mailtrap-go" +) + +func TestContacts_Create(t *testing.T) { + mux, client := setup(t) + mux.HandleFunc("POST /api/contacts", func(w http.ResponseWriter, r *http.Request) { + wantJSONBody(t, r, `{"contact":{"email":"john@example.com","fields":{"first_name":"John"},"list_ids":[1,2]}}`) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"data":{"id":"018dd5e3","email":"john@example.com","status":"subscribed","list_ids":[1,2]}}`)) + }) + + contact, _, err := client.Contacts.Create(context.Background(), &mailtrap.CreateContactRequest{ + Email: "john@example.com", + Fields: map[string]any{"first_name": "John"}, + ListIDs: []int64{1, 2}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if contact.ID != "018dd5e3" || contact.Status != mailtrap.ContactStatusSubscribed { + t.Errorf("contact = %+v", contact) + } +} + +func TestContacts_Get(t *testing.T) { + mux, client := setup(t) + mux.HandleFunc("GET /api/contacts/018dd5e3", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"id":"018dd5e3","email":"john@example.com","status":"unsubscribed"}}`)) + }) + + contact, _, err := client.Contacts.Get(context.Background(), "018dd5e3") + if err != nil { + t.Fatalf("Get: %v", err) + } + if contact.Email != "john@example.com" || contact.Status != mailtrap.ContactStatusUnsubscribed { + t.Errorf("contact = %+v", contact) + } +} + +func TestContacts_Get_byEmail(t *testing.T) { + mux, client := setup(t) + mux.HandleFunc("GET /api/contacts/john@example.com", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"id":"018dd5e3","email":"john@example.com"}}`)) + }) + + contact, _, err := client.Contacts.Get(context.Background(), "john@example.com") + if err != nil { + t.Fatalf("Get by email: %v", err) + } + if contact.ID != "018dd5e3" { + t.Errorf("contact = %+v", contact) + } +} + +func TestContacts_Update(t *testing.T) { + mux, client := setup(t) + mux.HandleFunc("PATCH /api/contacts/018dd5e3", func(w http.ResponseWriter, r *http.Request) { + wantJSONBody(t, r, `{"contact":{"email":"john@example.com","list_ids_included":[3],"list_ids_excluded":[1],"unsubscribed":true}}`) + _, _ = w.Write([]byte(`{"action":"updated","data":{"id":"018dd5e3","email":"john@example.com","status":"unsubscribed"}}`)) + }) + + upsert, _, err := client.Contacts.Update(context.Background(), "018dd5e3", &mailtrap.UpdateContactRequest{ + Email: "john@example.com", + ListIDsIncluded: []int64{3}, + ListIDsExcluded: []int64{1}, + Unsubscribed: mailtrap.Ptr(true), + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if upsert.Action != "updated" || upsert.Contact.ID != "018dd5e3" { + t.Errorf("upsert = %+v (contact %+v)", upsert, upsert.Contact) + } +} + +func TestContacts_Delete(t *testing.T) { + mux, client := setup(t) + mux.HandleFunc("DELETE /api/contacts/018dd5e3", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := client.Contacts.Delete(context.Background(), "018dd5e3") + if err != nil { + t.Fatalf("Delete: %v", err) + } + if resp.StatusCode != http.StatusNoContent { + t.Errorf("status = %d", resp.StatusCode) + } +} diff --git a/examples/contact-events/main.go b/examples/contact-events/main.go new file mode 100644 index 0000000..cc247dd --- /dev/null +++ b/examples/contact-events/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/mailtrap/mailtrap-go" +) + +func main() { + client, err := mailtrap.NewClient(os.Getenv("MAILTRAP_API_TOKEN")) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + + contact, _, err := client.Contacts.Create(ctx, &mailtrap.CreateContactRequest{ + Email: "john.smith@example.com", + }) + if err != nil { + log.Fatal(err) + } + + // Record a custom event for the contact (by UUID or email). + event, _, err := client.ContactEvents.Create(ctx, contact.ID, &mailtrap.CreateContactEventRequest{ + Name: "UserLogin", + Params: map[string]any{"user_id": 101, "is_active": true}, + }) + if err != nil { + log.Fatal(err) + } + fmt.Printf("recorded event %q for %s\n", event.Name, event.ContactEmail) +} diff --git a/examples/contacts/main.go b/examples/contacts/main.go new file mode 100644 index 0000000..06ac42b --- /dev/null +++ b/examples/contacts/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/mailtrap/mailtrap-go" +) + +func main() { + client, err := mailtrap.NewClient(os.Getenv("MAILTRAP_API_TOKEN")) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + + contact, _, err := client.Contacts.Create(ctx, &mailtrap.CreateContactRequest{ + Email: "john.smith@example.com", + Fields: map[string]any{"first_name": "John", "last_name": "Smith"}, + ListIDs: []int64{1, 2, 3}, + }) + if err != nil { + log.Fatal(err) + } + fmt.Printf("created contact %s (%s)\n", contact.ID, contact.Status) + + // Get by UUID or email (the email is URL-encoded for you). + got, _, err := client.Contacts.Get(ctx, contact.ID) + if err != nil { + log.Fatal(err) + } + fmt.Printf("contact email: %s\n", got.Email) + + // Update is an upsert; it reports whether the contact was created or updated. + upsert, _, err := client.Contacts.Update(ctx, contact.ID, &mailtrap.UpdateContactRequest{ + Email: "john.smith@example.com", + Fields: map[string]any{"first_name": "Johnny"}, + Unsubscribed: mailtrap.Ptr(true), + }) + if err != nil { + log.Fatal(err) + } + fmt.Printf("contact %s\n", upsert.Action) + + if _, err := client.Contacts.Delete(ctx, contact.ID); err != nil { + log.Fatal(err) + } + fmt.Println("deleted contact") +}