Configuring NICE CXone Email SMTP Relay Settings via API with Go

Configuring NICE CXone Email SMTP Relay Settings via API with Go

What You Will Build

  • A Go module that programmatically provisions CXone SMTP relay channels, validates server connectivity through test deliveries, and routes failed messages through a retry queue with exponential backoff.
  • The implementation uses the CXone Email Channel and Delivery Report REST APIs with OAuth2 client credentials authentication.
  • The code is written in Go 1.21 and relies on the standard library alongside golang.org/x/oauth2 for token management.

Prerequisites

  • CXone OAuth2 application configured for the Client Credentials grant type
  • Required scopes: email:write, email:read, email:test, monitoring:read
  • Go runtime version 1.21 or higher
  • External dependencies: golang.org/x/oauth2, golang.org/x/oauth2/clientcredentials
  • Access to a secret management system (HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault) for credential rotation
  • Network connectivity to api.cxone.com and your target SMTP relay server

Authentication Setup

CXone requires OAuth2 bearer tokens for all API calls. The client credentials flow exchanges your application ID and secret for a short-lived access token. Token caching prevents unnecessary authentication requests and reduces 429 rate-limit exposure.

package main

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"time"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/clientcredentials"
)

type CxoneClient struct {
	HTTP   *http.Client
	BaseURL string
	TokenSource oauth2.TokenSource
}

func NewCxoneClient(clientID, clientSecret string) (*CxoneClient, error) {
	cfg := &clientcredentials.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		TokenURL:     "https://api.cxone.com/v1/oauth2/token",
		Scopes:       []string{"email:write", "email:read", "email:test", "monitoring:read"},
	}

	ts := cfg.TokenSource(context.Background())

	// Configure HTTP client with TLS 1.2 minimum and connection pooling
	transport := &http.Transport{
		TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
		MaxIdleConns:    10,
		IdleConnTimeout: 90 * time.Second,
	}

	return &CxoneClient{
		HTTP:      &http.Client{Transport: transport, Timeout: 30 * time.Second},
		BaseURL:   "https://api.cxone.com",
		TokenSource: ts,
	}, nil
}

func (c *CxoneClient) DoRequest(ctx context.Context, method, path string, body any) (*http.Response, error) {
	var reqBody any
	if body != nil {
		jsonBytes, err := json.Marshal(body)
		if err != nil {
			return nil, fmt.Errorf("marshal request body: %w", err)
		}
		reqBody = jsonBytes
	}

	token, err := c.TokenSource.Token()
	if err != nil {
		return nil, fmt.Errorf("oauth2 token retrieval: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reqBody)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+token.AccessToken)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	return c.HTTP.Do(req)
}

The DoRequest method attaches the bearer token to every outbound call. If the token expires, TokenSource automatically refreshes it. This prevents 401 Unauthorized errors during long-running operations.

Implementation

Step 1: Construct and Deploy SMTP Configuration Payloads

CXone email channels require structured SMTP settings. The payload defines the relay endpoint, authentication mechanism, TLS enforcement, and connection timeouts. You must validate the structure before sending it to avoid 400 Bad Request responses.

type SMTPSettings struct {
	Server       string `json:"server"`
	Port         int    `json:"port"`
	Username     string `json:"username"`
	Password     string `json:"password"`
	TLSEnabled   bool   `json:"tlsEnabled"`
	AuthMechanism string `json:"authMechanism"` // PLAIN, LOGIN, or NONE
	TimeoutMs    int    `json:"timeoutMs"`
}

type EmailChannelPayload struct {
	Name          string       `json:"name"`
	Type          string       `json:"type"`
	SMTPSettings  SMTPSettings `json:"smtpSettings"`
	IsDefault     bool         `json:"isDefault"`
	MaxRetryCount int          `json:"maxRetryCount"`
}

type ChannelResponse struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	Status string `json:"status"`
}

func (c *CxoneClient) CreateSMTPChannel(ctx context.Context, payload EmailChannelPayload) (*ChannelResponse, error) {
	resp, err := c.DoRequest(ctx, http.MethodPost, "/api/v1/email/channels", payload)
	if err != nil {
		return nil, fmt.Errorf("create channel request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusConflict {
		return nil, fmt.Errorf("channel with this name already exists")
	}
	if resp.StatusCode != http.StatusCreated {
		return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}

	var result ChannelResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode channel response: %w", err)
	}

	slog.Info("SMTP channel provisioned", "id", result.ID, "name", result.Name)
	return &result, nil
}

The authMechanism field dictates how CXone authenticates against your relay server. PLAIN requires base64-encoded credentials, while LOGIN uses a two-step challenge. Set tlsEnabled to true to enforce STARTTLS or implicit TLS on port 465. The API returns a 409 Conflict if the channel name duplicates an existing configuration.

Step 2: Validate Connectivity and Analyze Error Codes

After provisioning, you must verify that CXone can establish a session with the relay server. The test endpoint simulates an outbound connection and returns detailed SMTP error codes.

type TestResponse struct {
	Success     bool   `json:"success"`
	StatusCode  int    `json:"statusCode"`
	ErrorCodes  []string `json:"errorCodes"`
	LatencyMs   int    `json:"latencyMs"`
	Message     string `json:"message"`
}

func (c *CxoneClient) TestSMTPConnectivity(ctx context.Context, channelID string) (*TestResponse, error) {
	resp, err := c.DoRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/email/channels/%s/test", channelID), nil)
	if err != nil {
		return nil, fmt.Errorf("test connectivity request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusTooManyRequests {
		return nil, fmt.Errorf("rate limited: retry after header not provided")
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("test request failed with status: %d", resp.StatusCode)
	}

	var result TestResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode test response: %w", err)
	}

	if !result.Success {
		slog.Warn("SMTP test failed", "codes", result.ErrorCodes, "latency", result.LatencyMs)
	} else {
		slog.Info("SMTP test passed", "latency", result.LatencyMs)
	}

	return &result, nil
}

CXone returns SMTP-level error codes in the errorCodes array. Common values include 421 (server closing connection), 530 (authentication required), and 550 (mailbox unavailable). The latencyMs field measures round-trip time from the CXone edge to your relay. High latency values indicate network routing issues rather than configuration errors.

Step 3: Implement Retry Logic and Dead-Letter Queue Handling

Failed deliveries require structured retry mechanisms. You will implement exponential backoff with a maximum attempt limit. Messages that exceed the limit move to a dead-letter queue for manual inspection.

type DeliveryAttempt struct {
	MessageID  string
	Attempts   int
	LastError  string
	NextRetry  time.Time
}

type DeadLetterQueue struct {
	mu      sync.Mutex
	entries []DeliveryAttempt
}

func (dlq *DeadLetterQueue) Push(entry DeliveryAttempt) {
	dlq.mu.Lock()
	defer dlq.mu.Unlock()
	dlq.entries = append(dlq.entries, entry)
	slog.Warn("message moved to dead-letter queue", "id", entry.MessageID, "attempts", entry.Attempts)
}

func CalculateBackoff(attempt int, baseDelay, maxDelay time.Duration) time.Duration {
	delay := baseDelay * (1 << uint(attempt-1))
	if delay > maxDelay {
		delay = maxDelay
	}
	return delay
}

func ProcessFailedDelivery(ctx context.Context, c *CxoneClient, attempt DeliveryAttempt, dlq *DeadLetterQueue) error {
	maxAttempts := 5
	baseDelay := 2 * time.Second
	maxDelay := 60 * time.Second

	if attempt.Attempts >= maxAttempts {
		dlq.Push(attempt)
		return nil
	}

	backoff := CalculateBackoff(attempt.Attempts, baseDelay, maxDelay)
	slog.Info("scheduling retry", "messageID", attempt.MessageID, "delay", backoff)

	timer := time.NewTimer(backoff)
	select {
	case <-ctx.Done():
		timer.Stop()
		return ctx.Err()
	case <-timer.C:
	}

	// Simulate retry call to CXone delivery API
	_, err := c.DoRequest(ctx, http.MethodPost, "/api/v1/email/delivery/retry", map[string]string{
		"messageId": attempt.MessageID,
	})
	if err != nil {
		attempt.Attempts++
		attempt.LastError = err.Error()
		return ProcessFailedDelivery(ctx, c, attempt, dlq)
	}

	slog.Info("retry successful", "messageID", attempt.MessageID)
	return nil
}

The backoff algorithm doubles the wait time after each failure. This prevents cascading 429 rate-limit errors when CXone or your relay server experiences temporary congestion. The sync.Mutex protects the dead-letter queue from concurrent writes during high-throughput scenarios.

Step 4: Synchronize Monitoring, Track Latency, and Generate Audit Logs

Operational visibility requires polling delivery metrics and recording configuration changes. You will fetch paginated delivery reports, calculate bounce rates, and write structured audit entries.

type DeliveryReport struct {
	Items      []DeliveryItem `json:"items"`
	PageSize   int            `json:"pageSize"`
	TotalCount int            `json:"totalCount"`
	NextPage   string         `json:"nextPage,omitempty"`
}

type DeliveryItem struct {
	MessageID   string    `json:"messageId"`
	Status      string    `json:"status"`
	LatencyMs   int       `json:"latencyMs"`
	Timestamp   time.Time `json:"timestamp"`
	BounceCode  string    `json:"bounceCode,omitempty"`
}

func FetchDeliveryReports(ctx context.Context, c *CxoneClient) ([]DeliveryItem, error) {
	var allItems []DeliveryItem
	page := ""

	for {
		path := "/api/v1/email/delivery/reports"
		if page != "" {
			path += "?page=" + page
		}

		resp, err := c.DoRequest(ctx, http.MethodGet, path, nil)
		if err != nil {
			return nil, fmt.Errorf("fetch reports: %w", err)
		}
		defer resp.Body.Close()

		if resp.StatusCode == http.StatusTooManyRequests {
			return nil, fmt.Errorf("rate limited during report fetch")
		}

		var report DeliveryReport
		if err := json.NewDecoder(resp.Body).Decode(&report); err != nil {
			return nil, fmt.Errorf("decode reports: %w", err)
		}

		allItems = append(allItems, report.Items...)
		if report.NextPage == "" {
			break
		}
		page = report.NextPage
	}

	return allItems, nil
}

func GenerateAuditLog(channelID, action, operator string, payload any) {
	logEntry := map[string]any{
		"timestamp":  time.Now().UTC().Format(time.RFC3339),
		"channel_id": channelID,
		"action":     action,
		"operator":   operator,
		"payload":    payload,
	}
	jsonBytes, _ := json.Marshal(logEntry)
	slog.Info("audit log entry created", "entry", string(jsonBytes))
}

func CalculateBounceRate(items []DeliveryItem) float64 {
	if len(items) == 0 {
		return 0
	}
	bounces := 0
	for _, item := range items {
		if item.Status == "bounced" || len(item.BounceCode) > 0 {
			bounces++
		}
	}
	return float64(bounces) / float64(len(items)) * 100
}

The pagination loop follows the nextPage cursor until the API returns an empty value. Bounce rates above 5 percent trigger sender reputation degradation. The audit function records every configuration mutation with a UTC timestamp and operator identifier for compliance reporting.

Step 5: Expose Local SMTP Tester Endpoint

You will create an HTTP handler that accepts test requests, validates parameters against CXone requirements, and returns structured results. This endpoint serves as a dry-run interface for CI/CD pipelines.

type TesterRequest struct {
	Server   string `json:"server"`
	Port     int    `json:"port"`
	Username string `json:"username"`
	Password string `json:"password"`
	TLSEnabled bool `json:"tlsEnabled"`
}

type TesterResponse struct {
	Valid    bool     `json:"valid"`
	Errors   []string `json:"errors,omitempty"`
	Metadata map[string]any `json:"metadata,omitempty"`
}

func SMTPTesterHandler(c *CxoneClient) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}

		var req TesterRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "invalid JSON payload", http.StatusBadRequest)
			return
		}

		var errors []string
		if req.Server == "" {
			errors = append(errors, "server endpoint is required")
		}
		if req.Port < 25 || req.Port > 1024 {
			errors = append(errors, "port must be between 25 and 1024")
		}
		if req.TLSEnabled && (req.Port != 465 && req.Port != 587) {
			errors = append(errors, "TLS requires port 465 or 587")
		}

		if len(errors) > 0 {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusBadRequest)
			json.NewEncoder(w).Encode(TesterResponse{Valid: false, Errors: errors})
			return
		}

		// Simulate CXone validation call
		payload := EmailChannelPayload{
			Name: "test-channel-" + fmt.Sprint(time.Now().Unix()),
			Type: "smtp",
			SMTPSettings: SMTPSettings{
				Server:       req.Server,
				Port:         req.Port,
				Username:     req.Username,
				Password:     req.Password,
				TLSEnabled:   req.TLSEnabled,
				AuthMechanism: "LOGIN",
				TimeoutMs:    15000,
			},
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(TesterResponse{
			Valid: true,
			Metadata: map[string]any{
				"payloadPreview": payload,
				"validationTime": time.Now().UTC().Format(time.RFC3339),
			},
		})
	}
}

The handler validates input constraints before constructing a CXone-compatible payload. It returns a 200 OK with a preview object when validation passes. CI/CD pipelines can call this endpoint to verify configuration syntax before deploying to production CXone environments.

Complete Working Example

package main

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"sync"
	"time"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/clientcredentials"
)

// [Previous struct definitions and methods from Steps 1-5 are included here in production]
// For brevity in this tutorial, they are consolidated below.

type CxoneClient struct {
	HTTP        *http.Client
	BaseURL     string
	TokenSource oauth2.TokenSource
}

type SMTPSettings struct {
	Server        string `json:"server"`
	Port          int    `json:"port"`
	Username      string `json:"username"`
	Password      string `json:"password"`
	TLSEnabled    bool   `json:"tlsEnabled"`
	AuthMechanism string `json:"authMechanism"`
	TimeoutMs     int    `json:"timeoutMs"`
}

type EmailChannelPayload struct {
	Name          string       `json:"name"`
	Type          string       `json:"type"`
	SMTPSettings  SMTPSettings `json:"smtpSettings"`
	IsDefault     bool         `json:"isDefault"`
	MaxRetryCount int          `json:"maxRetryCount"`
}

type ChannelResponse struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Status string `json:"status"`
}

type TestResponse struct {
	Success    bool     `json:"success"`
	StatusCode int      `json:"statusCode"`
	ErrorCodes []string `json:"errorCodes"`
	LatencyMs  int      `json:"latencyMs"`
	Message    string   `json:"message"`
}

type DeliveryReport struct {
	Items      []DeliveryItem `json:"items"`
	PageSize   int            `json:"pageSize"`
	TotalCount int            `json:"totalCount"`
	NextPage   string         `json:"nextPage,omitempty"`
}

type DeliveryItem struct {
	MessageID  string    `json:"messageId"`
	Status     string    `json:"status"`
	LatencyMs  int       `json:"latencyMs"`
	Timestamp  time.Time `json:"timestamp"`
	BounceCode string    `json:"bounceCode,omitempty"`
}

type DeliveryAttempt struct {
	MessageID string
	Attempts  int
	LastError string
}

type DeadLetterQueue struct {
	mu      sync.Mutex
	entries []DeliveryAttempt
}

func NewCxoneClient(clientID, clientSecret string) (*CxoneClient, error) {
	cfg := &clientcredentials.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		TokenURL:     "https://api.cxone.com/v1/oauth2/token",
		Scopes:       []string{"email:write", "email:read", "email:test", "monitoring:read"},
	}
	transport := &http.Transport{
		TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
		MaxIdleConns:    10,
		IdleConnTimeout: 90 * time.Second,
	}
	return &CxoneClient{
		HTTP:        &http.Client{Transport: transport, Timeout: 30 * time.Second},
		BaseURL:     "https://api.cxone.com",
		TokenSource: cfg.TokenSource(context.Background()),
	}, nil
}

func (c *CxoneClient) DoRequest(ctx context.Context, method, path string, body any) (*http.Response, error) {
	var reqBody any
	if body != nil {
		jsonBytes, err := json.Marshal(body)
		if err != nil {
			return nil, fmt.Errorf("marshal request body: %w", err)
		}
		reqBody = jsonBytes
	}
	token, err := c.TokenSource.Token()
	if err != nil {
		return nil, fmt.Errorf("oauth2 token retrieval: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reqBody)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+token.AccessToken)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")
	return c.HTTP.Do(req)
}

func (c *CxoneClient) CreateSMTPChannel(ctx context.Context, payload EmailChannelPayload) (*ChannelResponse, error) {
	resp, err := c.DoRequest(ctx, http.MethodPost, "/api/v1/email/channels", payload)
	if err != nil {
		return nil, fmt.Errorf("create channel request: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusConflict {
		return nil, fmt.Errorf("channel with this name already exists")
	}
	if resp.StatusCode != http.StatusCreated {
		return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}
	var result ChannelResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode channel response: %w", err)
	}
	slog.Info("SMTP channel provisioned", "id", result.ID, "name", result.Name)
	return &result, nil
}

func (c *CxoneClient) TestSMTPConnectivity(ctx context.Context, channelID string) (*TestResponse, error) {
	resp, err := c.DoRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/email/channels/%s/test", channelID), nil)
	if err != nil {
		return nil, fmt.Errorf("test connectivity request: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusTooManyRequests {
		return nil, fmt.Errorf("rate limited: retry after header not provided")
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("test request failed with status: %d", resp.StatusCode)
	}
	var result TestResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode test response: %w", err)
	}
	if !result.Success {
		slog.Warn("SMTP test failed", "codes", result.ErrorCodes, "latency", result.LatencyMs)
	} else {
		slog.Info("SMTP test passed", "latency", result.LatencyMs)
	}
	return &result, nil
}

func (dlq *DeadLetterQueue) Push(entry DeliveryAttempt) {
	dlq.mu.Lock()
	defer dlq.mu.Unlock()
	dlq.entries = append(dlq.entries, entry)
	slog.Warn("message moved to dead-letter queue", "id", entry.MessageID, "attempts", entry.Attempts)
}

func CalculateBackoff(attempt int, baseDelay, maxDelay time.Duration) time.Duration {
	delay := baseDelay * (1 << uint(attempt-1))
	if delay > maxDelay {
		delay = maxDelay
	}
	return delay
}

func ProcessFailedDelivery(ctx context.Context, c *CxoneClient, attempt DeliveryAttempt, dlq *DeadLetterQueue) error {
	maxAttempts := 5
	baseDelay := 2 * time.Second
	maxDelay := 60 * time.Second
	if attempt.Attempts >= maxAttempts {
		dlq.Push(attempt)
		return nil
	}
	backoff := CalculateBackoff(attempt.Attempts, baseDelay, maxDelay)
	slog.Info("scheduling retry", "messageID", attempt.MessageID, "delay", backoff)
	timer := time.NewTimer(backoff)
	select {
	case <-ctx.Done():
		timer.Stop()
		return ctx.Err()
	case <-timer.C:
	}
	_, err := c.DoRequest(ctx, http.MethodPost, "/api/v1/email/delivery/retry", map[string]string{
		"messageId": attempt.MessageID,
	})
	if err != nil {
		attempt.Attempts++
		attempt.LastError = err.Error()
		return ProcessFailedDelivery(ctx, c, attempt, dlq)
	}
	slog.Info("retry successful", "messageID", attempt.MessageID)
	return nil
}

func FetchDeliveryReports(ctx context.Context, c *CxoneClient) ([]DeliveryItem, error) {
	var allItems []DeliveryItem
	page := ""
	for {
		path := "/api/v1/email/delivery/reports"
		if page != "" {
			path += "?page=" + page
		}
		resp, err := c.DoRequest(ctx, http.MethodGet, path, nil)
		if err != nil {
			return nil, fmt.Errorf("fetch reports: %w", err)
		}
		defer resp.Body.Close()
		if resp.StatusCode == http.StatusTooManyRequests {
			return nil, fmt.Errorf("rate limited during report fetch")
		}
		var report DeliveryReport
		if err := json.NewDecoder(resp.Body).Decode(&report); err != nil {
			return nil, fmt.Errorf("decode reports: %w", err)
		}
		allItems = append(allItems, report.Items...)
		if report.NextPage == "" {
			break
		}
		page = report.NextPage
	}
	return allItems, nil
}

func GenerateAuditLog(channelID, action, operator string, payload any) {
	logEntry := map[string]any{
		"timestamp":  time.Now().UTC().Format(time.RFC3339),
		"channel_id": channelID,
		"action":     action,
		"operator":   operator,
		"payload":    payload,
	}
	jsonBytes, _ := json.Marshal(logEntry)
	slog.Info("audit log entry created", "entry", string(jsonBytes))
}

func CalculateBounceRate(items []DeliveryItem) float64 {
	if len(items) == 0 {
		return 0
	}
	bounces := 0
	for _, item := range items {
		if item.Status == "bounced" || len(item.BounceCode) > 0 {
			bounces++
		}
	}
	return float64(bounces) / float64(len(items)) * 100
}

type TesterRequest struct {
	Server     string `json:"server"`
	Port       int    `json:"port"`
	Username   string `json:"username"`
	Password   string `json:"password"`
	TLSEnabled bool   `json:"tlsEnabled"`
}

type TesterResponse struct {
	Valid    bool              `json:"valid"`
	Errors   []string          `json:"errors,omitempty"`
	Metadata map[string]any    `json:"metadata,omitempty"`
}

func SMTPTesterHandler(c *CxoneClient) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}
		var req TesterRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "invalid JSON payload", http.StatusBadRequest)
			return
		}
		var errors []string
		if req.Server == "" {
			errors = append(errors, "server endpoint is required")
		}
		if req.Port < 25 || req.Port > 1024 {
			errors = append(errors, "port must be between 25 and 1024")
		}
		if req.TLSEnabled && (req.Port != 465 && req.Port != 587) {
			errors = append(errors, "TLS requires port 465 or 587")
		}
		if len(errors) > 0 {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusBadRequest)
			json.NewEncoder(w).Encode(TesterResponse{Valid: false, Errors: errors})
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(TesterResponse{
			Valid: true,
			Metadata: map[string]any{
				"validationTime": time.Now().UTC().Format(time.RFC3339),
			},
		})
	}
}

func main() {
	ctx := context.Background()
	clientID := os.Getenv("CXONE_CLIENT_ID")
	clientSecret := os.Getenv("CXONE_CLIENT_SECRET")
	if clientID == "" || clientSecret == "" {
		slog.Error("CXONE_CLIENT_ID and CXONE_CLIENT_SECRET environment variables are required")
		os.Exit(1)
	}

	c, err := NewCxoneClient(clientID, clientSecret)
	if err != nil {
		slog.Error("failed to initialize CXone client", "error", err)
		os.Exit(1)
	}

	// Expose local tester endpoint
	go func() {
		http.HandleFunc("/smtp/test", SMTPTesterHandler(c))
		slog.Info("SMTP tester listening on :8080")
		if err := http.ListenAndServe(":8080", nil); err != nil {
			slog.Error("server failed", "error", err)
		}
	}()

	// Example workflow: create channel, test, fetch reports
	payload := EmailChannelPayload{
		Name: "prod-smtp-relay",
		Type: "smtp",
		SMTPSettings: SMTPSettings{
			Server:        "smtp.relay.example.com",
			Port:          587,
			Username:      "api-user",
			Password:      "secure-password-placeholder",
			TLSEnabled:    true,
			AuthMechanism: "LOGIN",
			TimeoutMs:     15000,
		},
		IsDefault:     false,
		MaxRetryCount: 3,
	}

	channel, err := c.CreateSMTPChannel(ctx, payload)
	if err != nil {
		slog.Error("channel creation failed", "error", err)
		os.Exit(1)
	}
	GenerateAuditLog(channel.ID, "CREATE", "ci-pipeline", payload)

	testResult, err := c.TestSMTPConnectivity(ctx, channel.ID)
	if err != nil {
		slog.Error("connectivity test failed", "error", err)
		os.Exit(1)
	}

	items, err := FetchDeliveryReports(ctx, c)
	if err != nil {
		slog.Error("report fetch failed", "error", err)
	} else {
		rate := CalculateBounceRate(items)
		slog.Info("delivery metrics", "bounce_rate", rate, "total_items", len(items))
	}

	// Keep running to accept tester requests
	select {}
}

Common Errors & Debugging

Error: 401 Unauthorized

  • What causes it: The OAuth2 token has expired or the client credentials are invalid.
  • How to fix it: Verify that CXONE_CLIENT_ID and CXONE_CLIENT_SECRET match your CXone application. Ensure the token source refreshes automatically. Add a retry wrapper that catches 401 and forces a token refresh before repeating the request.
  • Code showing the fix: The TokenSource in NewCxoneClient handles rotation automatically. If you receive repeated 401 errors, check that your system clock is synchronized within 60 seconds of the OAuth server.

Error: 403 Forbidden

  • What causes it: The registered OAuth scopes do not include email:write or email:test.
  • How to fix it: Navigate to your CXone application settings and add the missing scopes. Restart the Go application to pick up the new token payload.
  • Code showing the fix: The Scopes slice in clientcredentials.Config must match exactly. Mismatched scope names cause silent 403 responses.

Error: 429 Too Many Requests

  • What causes it: You have exceeded CXone rate limits (typically 100 requests per minute per client).
  • How to fix it: Implement token bucket rate limiting or parse the Retry-After header from the response. The ProcessFailedDelivery function already applies exponential backoff to prevent cascading limits.
  • Code showing the fix: Add time.Sleep(time.Duration(retryAfterSeconds) * time.Second) when resp.StatusCode == 429.

Error: 502 Bad Gateway or TLS Handshake Failure

  • What causes it: The relay server rejects the connection due to certificate validation failures or unsupported TLS versions.
  • How to fix it: Ensure TLSEnabled matches the server configuration. If using self-signed certificates, configure a custom tls.Config with InsecureSkipVerify: true for testing only. Production systems must use valid CA-signed certificates.
  • Code showing the fix: Replace the default transport with a custom http.Transport that includes your CA bundle in RootCAs.

Official References