Files
warmbly/internal/models/contact.go
T

615 lines
28 KiB
Go

package models
import (
"time"
"github.com/google/uuid"
)
// MiniCategory is the lightweight shape we attach to contact responses
// so the UI can render category chips without doing a second lookup. It
// is a denormalised slice of the row in `categories` plus nothing else.
type MiniCategory struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Color string `json:"color"`
}
type Contact struct {
ID uuid.UUID `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Company string `json:"company"`
Phone string `json:"phone"`
CustomFields map[string]string `json:"custom_fields"`
Subscribed bool `json:"subscribed"`
Campaigns []MiniCampaign `json:"campaigns"`
Categories []MiniCategory `json:"categories"`
// Pre-send verification state (see internal/pkg/emailverify). Populated by
// the verification scheduler / on-demand verify; the campaign send path uses
// VerificationStatus == "invalid" to drop addresses before a worker sends.
// VerificationStatus is one of: valid | risky | invalid | unknown.
VerificationStatus string `json:"verification_status"`
VerificationReason string `json:"verification_reason"`
IsCatchAll bool `json:"is_catch_all"`
VerificationCheckedAt *time.Time `json:"verification_checked_at,omitempty"`
// VerificationSource says who produced the verdict (see the
// VerificationSource* constants); VerificationProvider names the backend or
// the external vocabulary; VerificationSubStatus refines the status
// (catch_all, disposable, role, ...).
VerificationSource string `json:"verification_source"`
VerificationProvider string `json:"verification_provider"`
VerificationSubStatus string `json:"verification_sub_status"`
// VerificationConfidence is how sure the platform is of the status, 0 to
// 100, scored from the last check plus what real mail to the address
// showed (deliveries, opens, replies, bounces).
VerificationConfidence int `json:"verification_confidence"`
// Recipient ESP/provider, derived in the control plane from the recipient
// domain (never an MX dial on the send hot path). '' | 'gmail' | 'outlook'
// | 'other'. Used by the campaign ESP-matching feature.
ESPProvider string `json:"esp_provider"`
ESPResolvedAt *time.Time `json:"esp_resolved_at,omitempty"`
// CampaignLead is this contact's processing state WITHIN a single campaign.
// Populated by Search ONLY when the query filters by exactly one campaign
// (the campaign Leads view); nil otherwise. Lets the Leads list show which
// leads are queued, in progress, replied, bounced, or unsubscribed.
CampaignLead *ContactCampaignProgress `json:"campaign_lead,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
// ContactCampaignProgress is a contact's aggregate processing state inside one
// campaign, derived from campaign_contact_progress (across all of the campaign's
// steps) plus the contact's subscription flag.
type ContactCampaignProgress struct {
// Status is the single derived state shown in the Leads list:
// pending — a lead, but no email sent yet (queued)
// active — some but not all steps sent, still progressing through the flow
// completed — every sequence step was sent, no reply (done, nothing left to send)
// replied — the contact has replied (terminal/positive)
// bounced — a send hard-bounced (terminal/negative)
// failed — the mailbox could not send a step after every retry (terminal/negative)
// unsubscribed — the contact is unsubscribed/suppressed (terminal)
Status string `json:"status"`
Sent int `json:"sent"`
// Opened counts human opens only; automated fetches are in MachineOpened.
Opened int `json:"opened"`
MachineOpened int `json:"machine_opened"`
Clicked int `json:"clicked"`
Replied int `json:"replied"`
Bounced int `json:"bounced"`
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
// CurrentStep is the label of the step the contact is on now — the latest
// step actually sent ("Email 2", a custom step name, or an action label).
// Empty when nothing has been sent yet (status "pending").
CurrentStep string `json:"current_step,omitempty"`
// FailureReason is the worker's reason for the last failed send, set only
// when Status is "failed".
FailureReason string `json:"failure_reason,omitempty"`
}
// Lead status constants for ContactCampaignProgress.Status.
const (
LeadStatusPending = "pending"
LeadStatusActive = "active"
LeadStatusCompleted = "completed"
LeadStatusReplied = "replied"
LeadStatusBounced = "bounced"
LeadStatusFailed = "failed"
LeadStatusUnsubscribed = "unsubscribed"
// LeadStatusUndeliverable is a lead routing will never send to because
// address verification refused it (invalid, or risky with the campaign's
// "send to risky emails" toggle off). Distinct from pending, which is a
// lead still waiting its turn.
LeadStatusUndeliverable = "undeliverable"
)
// ValidLeadStatus reports whether s is one of the derived lead-status values.
// Used to gate the single-campaign Leads-view `lead_status` search filter.
func ValidLeadStatus(s string) bool {
switch s {
case LeadStatusPending, LeadStatusActive, LeadStatusCompleted, LeadStatusReplied, LeadStatusBounced, LeadStatusFailed, LeadStatusUnsubscribed, LeadStatusUndeliverable:
return true
default:
return false
}
}
// Lead engagement filter values for SearchContacts.Engagement. Each is a
// predicate over the contact's progress rows in one campaign; the negative
// forms only match leads that were sent at least one step, so a lead never
// emailed is neither "opened" nor "not opened".
const (
LeadEngagementOpened = "opened"
LeadEngagementNotOpened = "not_opened"
LeadEngagementClicked = "clicked"
LeadEngagementNotClicked = "not_clicked"
LeadEngagementReplied = "replied"
LeadEngagementNotReplied = "not_replied"
LeadEngagementBounced = "bounced"
)
// ValidLeadEngagement reports whether s is one of the engagement filter values.
func ValidLeadEngagement(s string) bool {
switch s {
case LeadEngagementOpened, LeadEngagementNotOpened, LeadEngagementClicked, LeadEngagementNotClicked, LeadEngagementReplied, LeadEngagementNotReplied, LeadEngagementBounced:
return true
default:
return false
}
}
type ContactsResult struct {
Data []Contact `json:"data"`
Pagination Pagination `json:"pagination"`
// Counts is populated only when the search request asks for it
// (`?counts=true`, first page). It carries org-wide facet totals for the
// browse sidebar (all/subscribed/unsubscribed/in-campaign/not-contacted +
// per category) and is independent of the request's filters, like the
// campaigns-overview drawer counts.
Counts *ContactsCounts `json:"counts,omitempty"`
// LeadCounts carries per-status lead totals for ONE campaign (the campaign
// Leads view). Populated only on the first page when the search filters by
// exactly one campaign_id; nil otherwise. Independent of the request's
// lead_status filter, so the scope chips can show every bucket's total.
LeadCounts *CampaignLeadCounts `json:"lead_counts,omitempty"`
}
// CampaignLeadCounts are per-status lead totals within a single campaign,
// derived the same way as ContactCampaignProgress.Status (unsubscribed >
// bounced > replied > failed > completed > processing > undeliverable >
// queued). Drives the Leads-view scope chips.
type CampaignLeadCounts struct {
Total int `json:"total"`
Queued int `json:"queued"` // pending: a lead, no email sent yet
Processing int `json:"processing"` // active: some steps sent, more to send
Completed int `json:"completed"` // done: every step sent, no reply
Replied int `json:"replied"`
Bounced int `json:"bounced"`
Failed int `json:"failed"` // a step could not be sent after every retry
Unsubscribed int `json:"unsubscribed"`
// Undeliverable: address verification refused it, so routing skips it.
Undeliverable int `json:"undeliverable"`
// Engagement totals, matching the `engagement` search filter: leads sent
// at least one step, and of those the ones with a human open, a click, or
// a reply on any step.
Contacted int `json:"contacted"`
Opened int `json:"opened"`
Clicked int `json:"clicked"`
RepliedAny int `json:"replied_any"`
}
// ContactsCounts are org-wide contact facet totals for the browse sidebar.
type ContactsCounts struct {
Total int `json:"total"`
Subscribed int `json:"subscribed"`
Unsubscribed int `json:"unsubscribed"`
InCampaign int `json:"in_campaign"`
NotContacted int `json:"not_contacted"`
Categories []ContactCategoryCount `json:"categories"`
Verification ContactVerificationCounts `json:"verification"`
}
// ContactVerificationCounts is the org's contacts by verification status.
// Pending is the subset of Unknown nobody has checked yet.
type ContactVerificationCounts struct {
Valid int `json:"valid"`
Risky int `json:"risky"`
Invalid int `json:"invalid"`
Unknown int `json:"unknown"`
Pending int `json:"pending"`
}
// ContactVerificationDetail is the "why" behind a contact's verdict.
type ContactVerificationDetail struct {
Status string `json:"status"`
Confidence int `json:"confidence"`
Reasons []string `json:"reasons"`
// Decisive is true when real mail, not a check, decided the status.
Decisive bool `json:"decisive"`
Evidence []ContactVerificationEvidence `json:"evidence"`
}
// ContactVerificationEvidence is one observed fact about the mailbox.
type ContactVerificationEvidence struct {
Kind string `json:"kind"`
Detail string `json:"detail,omitempty"`
ObservedAt time.Time `json:"observed_at"`
}
// Verification provenance values for contacts.verification_source.
const (
VerificationSourceNone = ""
VerificationSourceProbe = "probe"
VerificationSourceProvider = "provider"
VerificationSourceImported = "imported"
VerificationSourceManual = "manual"
)
// ContactVerificationWrite is a verdict to store on a contact, already
// normalised into Warmbly's vocabulary.
type ContactVerificationWrite struct {
Status string
SubStatus string
Reason string
Provider string
Source string
}
// Actions for POST /contacts/verification.
const (
ContactVerificationActionVerify = "verify"
ContactVerificationActionMarkDeliverable = "mark_deliverable"
ContactVerificationActionMarkUndeliverable = "mark_undeliverable"
)
// ContactVerificationRequest is the body of POST /contacts/verification.
type ContactVerificationRequest struct {
Contacts []string `json:"contacts"`
// CampaignID selects every lead of one campaign that verification refused
// (the "re-verify skipped leads" action), instead of listing ids.
CampaignID string `json:"campaign_id,omitempty"`
Action string `json:"action"`
}
// ContactVerificationResponse reports how many contacts the action touched.
type ContactVerificationResponse struct {
Affected int `json:"affected"`
Action string `json:"action"`
// Queued is true for the verify action: the check runs in the background
// and each contact updates live as its verdict lands.
Queued bool `json:"queued"`
}
// VerificationOverview is what Settings shows about address verification.
type VerificationOverview struct {
// Provider is who checks this workspace's addresses: "builtin" or
// "millionverifier".
Provider string `json:"provider"`
// ConnectionID is the integration connection behind a paid provider.
ConnectionID *string `json:"connection_id,omitempty"`
// Credits is the paid provider's remaining balance when it could be read.
Credits *int `json:"credits,omitempty"`
// ProviderError is set when the paid provider is connected but unusable
// (bad key, no credits), in which case the built-in check is in use.
ProviderError string `json:"provider_error,omitempty"`
// BuiltinReady says whether the in-house probe can reach mail servers from
// this instance (a HELO host is configured). Off, it still checks syntax,
// MX, and disposable domains.
BuiltinReady bool `json:"builtin_ready"`
Counts ContactVerificationCounts `json:"counts"`
}
// ContactCategoryCount is the number of org contacts carrying one category.
type ContactCategoryCount struct {
CategoryID string `json:"category_id"`
Count int `json:"count"`
}
// ContactEngagement summarises every email touchpoint we have for a
// single contact. It's denormalised on read so the contact 360 view
// can render counts and "last X" timestamps in a single round-trip.
type ContactEngagement struct {
TotalSent int `json:"total_sent"`
TotalOpened int `json:"total_opened"`
TotalClicked int `json:"total_clicked"`
TotalReplied int `json:"total_replied"`
TotalBounced int `json:"total_bounced"`
TotalComplained int `json:"total_complained"`
LastSentAt *time.Time `json:"last_sent_at,omitempty"`
LastOpenedAt *time.Time `json:"last_opened_at,omitempty"`
LastClickedAt *time.Time `json:"last_clicked_at,omitempty"`
LastRepliedAt *time.Time `json:"last_replied_at,omitempty"`
LastBouncedAt *time.Time `json:"last_bounced_at,omitempty"`
}
// ContactSuppression mirrors a row from suppressed_recipients for the
// contact's email. Null on the wire when the contact is not suppressed.
type ContactSuppression struct {
Reason string `json:"reason"`
Source string `json:"source"` // bounce | complaint | unsubscribe
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ContactDetail is the hydrated read model returned by GET /contacts/:id.
// It bundles everything the slide-over needs in one payload so the UI
// doesn't have to fan out a half-dozen requests on open.
type ContactDetail struct {
Contact
Engagement ContactEngagement `json:"engagement"`
Suppression *ContactSuppression `json:"suppression,omitempty"`
// Verification explains the verdict: the reasons behind it and the
// observations it was scored from.
Verification *ContactVerificationDetail `json:"verification,omitempty"`
// First-touch attribution. Source never changes after creation.
Source ContactSource `json:"source"`
SourceDetail string `json:"source_detail"`
FirstSeenAt time.Time `json:"first_seen_at"`
}
// ContactSentEmail is one row in the "Emails sent to this contact"
// list. Each row corresponds to a single delivered (or attempted)
// task. Engagement timestamps come from campaign_contact_progress
// when present; some legacy rows may have nil progress.
type ContactSentEmail struct {
TaskID uuid.UUID `json:"task_id"`
Status string `json:"status"`
MessageID string `json:"message_id"`
Subject string `json:"subject"`
SentAt time.Time `json:"sent_at"`
// Sender mailbox
EmailAccountID *uuid.UUID `json:"email_account_id,omitempty"`
EmailAccountEmail *string `json:"email_account_email,omitempty"`
EmailAccountName *string `json:"email_account_name,omitempty"`
// Campaign + sequence context
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
CampaignName *string `json:"campaign_name,omitempty"`
SequenceID *uuid.UUID `json:"step_id,omitempty"`
SequenceName *string `json:"step_name,omitempty"`
// Engagement (from campaign_contact_progress, may be nil).
OpenedAt *time.Time `json:"opened_at,omitempty"`
ClickedAt *time.Time `json:"clicked_at,omitempty"`
RepliedAt *time.Time `json:"replied_at,omitempty"`
BouncedAt *time.Time `json:"bounced_at,omitempty"`
}
type ContactSentEmailsResult struct {
Data []ContactSentEmail `json:"data"`
Pagination Pagination `json:"pagination"`
}
// ContactTimelineEventType is a closed enum so the frontend can pick
// the right icon / colour without parsing free text.
type ContactTimelineEventType string
const (
TimelineEmailSent ContactTimelineEventType = "email_sent"
TimelineEmailOpened ContactTimelineEventType = "email_opened"
TimelineEmailClicked ContactTimelineEventType = "email_clicked"
TimelineEmailReplied ContactTimelineEventType = "email_replied"
TimelineEmailBounced ContactTimelineEventType = "email_bounced"
TimelineReplyReceived ContactTimelineEventType = "reply_received"
TimelineDeliverability ContactTimelineEventType = "deliverability"
TimelineSuppressed ContactTimelineEventType = "suppressed"
TimelineNote ContactTimelineEventType = "note"
// Meetings booked through a connected scheduling provider (Calendly /
// Cal.com). The event time is when the booking arrived; ScheduledFor holds
// when the call itself is set for.
TimelineMeetingBooked ContactTimelineEventType = "meeting_booked"
TimelineMeetingRescheduled ContactTimelineEventType = "meeting_rescheduled"
TimelineMeetingCanceled ContactTimelineEventType = "meeting_canceled"
// Lifecycle events, read from contact_activities. contact_created carries
// the first-touch Source (and SourceDetail), which is how "imported" and
// "created via API" are told apart without a type per origin.
TimelineContactCreated ContactTimelineEventType = "contact_created"
TimelineCampaignAdded ContactTimelineEventType = "campaign_added"
TimelineCampaignRemoved ContactTimelineEventType = "campaign_removed"
TimelineCategoryAdded ContactTimelineEventType = "category_added"
TimelineCategoryRemoved ContactTimelineEventType = "category_removed"
// A page view from the website tracking snippet, tied to the contact
// through an email-link ticket. Detail rides in PageHit.
TimelinePageHit ContactTimelineEventType = "page_hit"
)
// ContactTimelineEvent is one entry in the merged activity feed. The
// optional fields are tagged with omitempty so the JSON stays compact
// for event types that don't carry that data.
type ContactTimelineEvent struct {
Type ContactTimelineEventType `json:"type"`
At time.Time `json:"at"`
// Mailbox sender (email_sent / opened / clicked / replied / bounced).
EmailAccountID *uuid.UUID `json:"email_account_id,omitempty"`
EmailAccountEmail *string `json:"email_account_email,omitempty"`
EmailAccountName *string `json:"email_account_name,omitempty"`
// Campaign / sequence linkage. Optional because notes, suppression,
// and out-of-campaign reply intents don't always have one.
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
CampaignName *string `json:"campaign_name,omitempty"`
SequenceID *uuid.UUID `json:"step_id,omitempty"`
SequenceName *string `json:"step_name,omitempty"`
// Task linkage for engagement events.
TaskID *uuid.UUID `json:"task_id,omitempty"`
Subject *string `json:"subject,omitempty"`
// Type-specific.
Reason *string `json:"reason,omitempty"` // deliverability / suppression / meeting cancellation
Source *string `json:"source,omitempty"` // suppression: bounce/complaint/unsubscribe; meeting: calendly/cal_com
Provider *string `json:"provider,omitempty"` // deliverability provider
Intent *string `json:"intent,omitempty"` // reply_intent classification
Content *string `json:"content,omitempty"` // note body
// Meeting events (meeting_booked / rescheduled / canceled).
ScheduledFor *time.Time `json:"scheduled_for,omitempty"` // when the call is set for
JoinURL *string `json:"join_url,omitempty"` // video/conference link
MeetingState *string `json:"meeting_state,omitempty"` // booked / rescheduled / canceled
// Lifecycle events: the category a contact joined or left, and the
// free-form detail behind a contact_created source (file name, campaign,
// API key).
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryTitle *string `json:"category_title,omitempty"`
SourceDetail *string `json:"source_detail,omitempty"`
// Website page view (page_hit): URL, referrer, device, UTM, location.
PageHit *WebsitePageHit `json:"page_hit,omitempty"`
// Author (notes, lifecycle events).
UserID *uuid.UUID `json:"user_id,omitempty"`
}
type ContactTimelineResult struct {
Data []ContactTimelineEvent `json:"data"`
// True if we hit the per-call cap and the caller should paginate
// via the `before` query param.
HasMore bool `json:"has_more"`
}
type UpdateContact struct {
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
Company *string `json:"company"`
Phone *string `json:"phone"`
CustomFields *map[string]string `json:"custom_fields"`
Subscribed *bool `json:"subscribed"`
Campaigns []string `json:"campaigns"` // List of campaign IDs to set (nil = leave as-is)
Categories []string `json:"categories"` // List of category IDs to set (nil = leave as-is)
AddCategories []string `json:"add_categories"` // Diff-style add (ignored when Categories is set)
RemoveCategories []string `json:"remove_categories"` // Diff-style remove (ignored when Categories is set)
}
type AddContact struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Company string `json:"company"`
Phone string `json:"phone"`
Campaigns []string `json:"campaigns"`
Categories []string `json:"categories"`
// Segments the new contact joins as an include override, so a contact
// created from inside a segment belongs to it whatever the conditions
// say. Unknown ids are a 400 before anything is written.
Segments []string `json:"segments,omitempty"`
CustomFields map[string]string `json:"custom_fields"`
// VerificationStatus is a verdict the caller already holds for this
// address, in Warmbly's vocabulary or any provider's the platform knows
// (ZeroBounce, MillionVerifier, NeverBounce, ...). VerificationProvider
// optionally names that vocabulary; without it the value is recognised by
// itself. An unrecognised value is a 400. Stored as an imported verdict,
// which the background check leaves alone until it ages out.
VerificationStatus string `json:"verification_status,omitempty"`
VerificationProvider string `json:"verification_provider,omitempty"`
// Verification is the normalised verdict derived from VerificationStatus.
// Filled by the repository, never read from the request.
Verification *ContactVerificationWrite `json:"-"`
// Subscribed is the marketing-consent flag to store. nil means "don't
// decide": a new contact defaults to subscribed, an existing one keeps
// whatever it already had. Set explicitly by the importer when the file
// carries a subscribed column.
Subscribed *bool `json:"subscribed,omitempty"`
// Source is the first-touch origin stamped on a NEW contact (an existing
// one keeps its original). Dashboard callers may say "manual" or
// "campaign"; every other value is decided server-side by the creation
// path, and an API-key request is always "api". SourceDetail is never
// read from the request body.
Source ContactSource `json:"source,omitempty"`
SourceDetail string `json:"-"`
}
// ContactSource is where a contact first came from. Mirrors the CHECK on
// contacts.source; ContactSourceUnknown is the honest value for rows created
// before attribution existed.
type ContactSource string
const (
ContactSourceUnknown ContactSource = "unknown"
ContactSourceManual ContactSource = "manual"
ContactSourceCampaign ContactSource = "campaign"
ContactSourceImport ContactSource = "import"
ContactSourceSheetSync ContactSource = "sheet_sync"
ContactSourceAPI ContactSource = "api"
ContactSourceAIAssistant ContactSource = "ai_assistant"
)
// Valid reports whether the value is one the database accepts.
func (s ContactSource) Valid() bool {
switch s {
case ContactSourceUnknown, ContactSourceManual, ContactSourceCampaign, ContactSourceImport,
ContactSourceSheetSync, ContactSourceAPI, ContactSourceAIAssistant:
return true
}
return false
}
// RequestSettable reports whether a dashboard request body may claim the
// source. Only the two origins a dashboard user can actually be in.
func (s ContactSource) RequestSettable() bool {
return s == ContactSourceManual || s == ContactSourceCampaign
}
type SearchContactsFilterType string
const (
SearchContactsFilterTypeEqual SearchContactsFilterType = "equal"
SearchContactsFilterTypeStartsWith SearchContactsFilterType = "starts_with"
SearchContactsFilterTypeEndsWith SearchContactsFilterType = "ends_with"
SearchContactsFilterTypeContains SearchContactsFilterType = "contains"
)
type SearchContactsFilter struct {
Name string `json:"name"`
Value string `json:"value"`
Type SearchContactsFilterType `json:"type"`
}
type SearchContacts struct {
Query string `json:"query"` // Text search across core fields
CustomFieldFilters []SearchContactsFilter `json:"custom_field_filters"` // Custom Field Filters
CampaignIDs []string `json:"campaign_ids"` // Contacts must be in ALL these campaigns
LeadStatus string `json:"lead_status"` // Filter by derived lead status; requires exactly one campaign_id
Engagement string `json:"engagement"` // Filter by lead engagement (opened, not_opened, ...); ANDed with lead_status; requires exactly one campaign_id
CategoryIDs []string `json:"category_ids"` // Contacts must have ALL these categories
SegmentIDs []string `json:"segment_ids"` // Contacts must be members of ALL these segments
MinCampaigns *int `json:"min_campaigns"` // Minimum number of associated campaigns
MaxCampaigns *int `json:"max_campaigns"` // Maximum number of associated campaigns
Subscribed *bool `json:"subscribed"` // Filter by subscription status
VerificationStatus string `json:"verification_status"` // Filter by verification verdict: valid | risky | invalid | unknown
CreatedAfter *time.Time `json:"created_after"` // Contacts created after this date
CreatedBefore *time.Time `json:"created_before"` // Contacts created before this date
UpdatedAfter *time.Time `json:"updated_after"` // Contacts updated after this date
UpdatedBefore *time.Time `json:"updated_before"` // Contacts updated before this date
SortBy string `json:"sort_by"` // e.g., "first_name ASC", "campaign_count DESC"
Reverse bool `json:"reverse"` // ASC or DESC
}
type BulkEditContactsFieldType string
const (
BulkAddField BulkEditContactsFieldType = "ADD"
BulkEditField BulkEditContactsFieldType = "EDIT"
BulkDeleteField BulkEditContactsFieldType = "DELETE"
BulkRenameField BulkEditContactsFieldType = "RENAME"
)
type BulkEditContactsField struct {
Type BulkEditContactsFieldType `json:"type"`
Key string `json:"key"`
Value string `json:"value"`
}
type BulkEditContactsData struct {
Contacts []string `json:"contacts"`
AddCampaigns []string `json:"add_campaigns"`
RemoveCampaigns []string `json:"remove_campaigns"`
AddCategories []string `json:"add_categories,omitempty"`
RemoveCategories []string `json:"remove_categories,omitempty"`
Fields []BulkEditContactsField `json:"fields"`
Subscribe *bool `json:"subscribe"`
}