github.com/letsencrypt/boulder@v0.20251208.0/email/pardot.go (about)

     1  package email
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"net/url"
    10  	"sync"
    11  	"time"
    12  
    13  	"github.com/jmhodges/clock"
    14  	"github.com/letsencrypt/boulder/core"
    15  )
    16  
    17  const (
    18  	// tokenPath is the path to the Salesforce OAuth2 token endpoint.
    19  	tokenPath = "/services/oauth2/token"
    20  
    21  	// contactsPath is the path to the Pardot v5 Prospect upsert-by-email
    22  	// endpoint. This endpoint will create a new Prospect if one does not
    23  	// already exist with the same email address.
    24  	//
    25  	// https://developer.salesforce.com/docs/marketing/pardot/guide/prospect-v5.html#prospect-upsert-by-email
    26  	contactsPath = "/api/v5/objects/prospects/do/upsertLatestByEmail"
    27  
    28  	// casesPath is the path to create a new Case object in Salesforce. This
    29  	// path includes the API version (v64.0). Normally, Salesforce maintains
    30  	// backward compatibility across versions. Update only if Salesforce retires
    31  	// this API version (rare) or we want to make use of new Case fields
    32  	// (unlikely).
    33  	//
    34  	// To check the current version for our org, see "Identify your current API
    35  	// version": https://help.salesforce.com/s/articleView?id=000386929&type=1
    36  	casesPath = "/services/data/v64.0/sobjects/Case"
    37  
    38  	// maxAttempts is the maximum number of attempts to retry a request.
    39  	maxAttempts = 3
    40  
    41  	// retryBackoffBase is the base for exponential backoff.
    42  	retryBackoffBase = 2.0
    43  
    44  	// retryBackoffMax is the maximum backoff time.
    45  	retryBackoffMax = 10 * time.Second
    46  
    47  	// retryBackoffMin is the minimum backoff time.
    48  	retryBackoffMin = 200 * time.Millisecond
    49  
    50  	// tokenExpirationBuffer is the time before the token expires that we will
    51  	// attempt to refresh it.
    52  	tokenExpirationBuffer = 5 * time.Minute
    53  )
    54  
    55  // SalesforceClient is an interface for interacting with a limited set of
    56  // Salesforce APIs. It exists to facilitate testing mocks.
    57  type SalesforceClient interface {
    58  	SendContact(email string) error
    59  	SendCase(payload Case) error
    60  }
    61  
    62  // oAuthToken holds the OAuth2 access token and its expiration.
    63  type oAuthToken struct {
    64  	sync.Mutex
    65  
    66  	accessToken string
    67  	expiresAt   time.Time
    68  }
    69  
    70  // SalesforceClientImpl handles authentication and sending contacts to Pardot
    71  // and creating Cases in Salesforce.
    72  type SalesforceClientImpl struct {
    73  	businessUnit string
    74  	clientId     string
    75  	clientSecret string
    76  	pardotURL    string
    77  	casesURL     string
    78  	tokenURL     string
    79  	token        *oAuthToken
    80  	clk          clock.Clock
    81  }
    82  
    83  var _ SalesforceClient = (*SalesforceClientImpl)(nil)
    84  
    85  // NewSalesforceClientImpl creates a new SalesforceClientImpl.
    86  func NewSalesforceClientImpl(clk clock.Clock, businessUnit, clientId, clientSecret, salesforceBaseURL, pardotBaseURL string) (*SalesforceClientImpl, error) {
    87  	pardotURL, err := url.JoinPath(pardotBaseURL, contactsPath)
    88  	if err != nil {
    89  		return nil, fmt.Errorf("failed to join contacts path: %w", err)
    90  	}
    91  	tokenURL, err := url.JoinPath(salesforceBaseURL, tokenPath)
    92  	if err != nil {
    93  		return nil, fmt.Errorf("failed to join token path: %w", err)
    94  	}
    95  	casesURL, err := url.JoinPath(salesforceBaseURL, casesPath)
    96  	if err != nil {
    97  		return nil, fmt.Errorf("failed to join cases path: %w", err)
    98  	}
    99  
   100  	return &SalesforceClientImpl{
   101  		businessUnit: businessUnit,
   102  		clientId:     clientId,
   103  		clientSecret: clientSecret,
   104  		pardotURL:    pardotURL,
   105  		casesURL:     casesURL,
   106  		tokenURL:     tokenURL,
   107  		token:        &oAuthToken{},
   108  		clk:          clk,
   109  	}, nil
   110  }
   111  
   112  type oauthTokenResp struct {
   113  	AccessToken string `json:"access_token"`
   114  	ExpiresIn   int    `json:"expires_in"`
   115  }
   116  
   117  // updateToken updates the OAuth token if necessary.
   118  func (pc *SalesforceClientImpl) updateToken() error {
   119  	pc.token.Lock()
   120  	defer pc.token.Unlock()
   121  
   122  	now := pc.clk.Now()
   123  	if now.Before(pc.token.expiresAt.Add(-tokenExpirationBuffer)) && pc.token.accessToken != "" {
   124  		return nil
   125  	}
   126  
   127  	resp, err := http.PostForm(pc.tokenURL, url.Values{
   128  		"grant_type":    {"client_credentials"},
   129  		"client_id":     {pc.clientId},
   130  		"client_secret": {pc.clientSecret},
   131  	})
   132  	if err != nil {
   133  		return fmt.Errorf("failed to retrieve token: %w", err)
   134  	}
   135  	defer resp.Body.Close()
   136  
   137  	if resp.StatusCode != http.StatusOK {
   138  		body, readErr := io.ReadAll(resp.Body)
   139  		if readErr != nil {
   140  			return fmt.Errorf("token request failed with status %d; while reading body: %w", resp.StatusCode, readErr)
   141  		}
   142  		return fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, body)
   143  	}
   144  
   145  	var respJSON oauthTokenResp
   146  	err = json.NewDecoder(resp.Body).Decode(&respJSON)
   147  	if err != nil {
   148  		return fmt.Errorf("failed to decode token response: %w", err)
   149  	}
   150  	pc.token.accessToken = respJSON.AccessToken
   151  	pc.token.expiresAt = pc.clk.Now().Add(time.Duration(respJSON.ExpiresIn) * time.Second)
   152  
   153  	return nil
   154  }
   155  
   156  // redactEmail replaces all occurrences of an email address in a response body
   157  // with "[REDACTED]".
   158  func redactEmail(body []byte, email string) string {
   159  	return string(bytes.ReplaceAll(body, []byte(email), []byte("[REDACTED]")))
   160  }
   161  
   162  type prospect struct {
   163  	// Email is the email address of the prospect.
   164  	Email string `json:"email"`
   165  }
   166  
   167  type upsertPayload struct {
   168  	// MatchEmail is the email address to match against existing prospects to
   169  	// avoid adding duplicates.
   170  	MatchEmail string `json:"matchEmail"`
   171  	// Prospect is the prospect data to be upserted.
   172  	Prospect prospect `json:"prospect"`
   173  }
   174  
   175  // SendContact submits an email to the Pardot Contacts endpoint, retrying up
   176  // to 3 times with exponential backoff.
   177  func (pc *SalesforceClientImpl) SendContact(email string) error {
   178  	var err error
   179  	for attempt := range maxAttempts {
   180  		time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
   181  		err = pc.updateToken()
   182  		if err != nil {
   183  			continue
   184  		}
   185  		break
   186  	}
   187  	if err != nil {
   188  		return fmt.Errorf("failed to update token: %w", err)
   189  	}
   190  
   191  	payload, err := json.Marshal(upsertPayload{
   192  		MatchEmail: email,
   193  		Prospect:   prospect{Email: email},
   194  	})
   195  	if err != nil {
   196  		return fmt.Errorf("failed to marshal payload: %w", err)
   197  	}
   198  
   199  	var finalErr error
   200  	for attempt := range maxAttempts {
   201  		time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
   202  
   203  		req, err := http.NewRequest("POST", pc.pardotURL, bytes.NewReader(payload))
   204  		if err != nil {
   205  			finalErr = fmt.Errorf("failed to create new contact request: %w", err)
   206  			continue
   207  		}
   208  		req.Header.Set("Content-Type", "application/json")
   209  		req.Header.Set("Authorization", "Bearer "+pc.token.accessToken)
   210  		req.Header.Set("Pardot-Business-Unit-Id", pc.businessUnit)
   211  
   212  		resp, err := http.DefaultClient.Do(req)
   213  		if err != nil {
   214  			finalErr = fmt.Errorf("create contact request failed: %w", err)
   215  			continue
   216  		}
   217  
   218  		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
   219  			resp.Body.Close()
   220  			return nil
   221  		}
   222  
   223  		body, err := io.ReadAll(resp.Body)
   224  		resp.Body.Close()
   225  
   226  		if err != nil {
   227  			finalErr = fmt.Errorf("create contact request returned status %d; while reading body: %w", resp.StatusCode, err)
   228  			continue
   229  		}
   230  		finalErr = fmt.Errorf("create contact request returned status %d: %s", resp.StatusCode, redactEmail(body, email))
   231  		continue
   232  	}
   233  
   234  	return finalErr
   235  }
   236  
   237  // Case represents the payload for populating a new Case object in Salesforce.
   238  // For more information, see:
   239  // https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_case.htm
   240  // https://help.salesforce.com/s/articleView?id=platform.custom_field_types.htm&type=5
   241  type Case struct {
   242  	// Origin is required in all requests, a safe default is "Web".
   243  	Origin string `json:"Origin"`
   244  
   245  	// Subject is an optional standard field. Max length: 255 characters.
   246  	Subject string `json:"Subject,omitempty"`
   247  
   248  	// Description is an optional standard field. Max length: 32,768 characters.
   249  	Description string `json:"Description,omitempty"`
   250  
   251  	// ContactEmail is an optional standard field indicating the email address
   252  	// of the requester. Max length: 80 characters.
   253  	ContactEmail string `json:"ContactEmail,omitempty"`
   254  
   255  	// Note: Fields below this point are optional custom fields.
   256  
   257  	// Organization indicates the name of the requesting organization. Max
   258  	// length: 255 characters.
   259  	Organization string `json:"Organization__c,omitempty"`
   260  
   261  	// AccountId indicates the requester's ACME Account ID. Max length: 255
   262  	// characters.
   263  	AccountId string `json:"Account_ID__c,omitempty"`
   264  
   265  	// RateLimitName indicates which rate limit the override request is for. Max
   266  	// length: 255 characters.
   267  	RateLimitName string `json:"Rate_Limit_Name__c,omitempty"`
   268  
   269  	// Tier indicates the requested tier of the rate limit override. Max length:
   270  	// 255 characters.
   271  	RateLimitTier string `json:"Rate_Limit_Tier__c,omitempty"`
   272  
   273  	// UseCase indicates the intended to use case supplied by the requester. Max
   274  	// length: 131,072 characters.
   275  	UseCase string `json:"Use_Case__c,omitempty"`
   276  }
   277  
   278  // SendCase submits a new Case object to Salesforce. For more information, see:
   279  // https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_create.htm
   280  func (pc *SalesforceClientImpl) SendCase(payload Case) error {
   281  	var err error
   282  	for attempt := range maxAttempts {
   283  		time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
   284  		err = pc.updateToken()
   285  		if err == nil {
   286  			break
   287  		}
   288  	}
   289  	if err != nil {
   290  		return fmt.Errorf("failed to update token: %w", err)
   291  	}
   292  
   293  	body, err := json.Marshal(payload)
   294  	if err != nil {
   295  		return fmt.Errorf("failed to marshal case payload: %w", err)
   296  	}
   297  
   298  	var finalErr error
   299  	for attempt := range maxAttempts {
   300  		time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
   301  
   302  		req, err := http.NewRequest("POST", pc.casesURL, bytes.NewReader(body))
   303  		if err != nil {
   304  			finalErr = fmt.Errorf("failed to create new case request: %w", err)
   305  			continue
   306  		}
   307  		req.Header.Set("Content-Type", "application/json")
   308  		req.Header.Set("Authorization", "Bearer "+pc.token.accessToken)
   309  
   310  		resp, err := http.DefaultClient.Do(req)
   311  		if err != nil {
   312  			finalErr = fmt.Errorf("create case request failed: %w", err)
   313  			continue
   314  		}
   315  
   316  		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
   317  			resp.Body.Close()
   318  			return nil
   319  		}
   320  
   321  		respBody, err := io.ReadAll(resp.Body)
   322  		resp.Body.Close()
   323  
   324  		if err != nil {
   325  			finalErr = fmt.Errorf("create case request returned status %d; while reading body: %w", resp.StatusCode, err)
   326  			continue
   327  		}
   328  
   329  		finalErr = fmt.Errorf("create case request returned status %d: %s", resp.StatusCode, redactEmail(respBody, payload.ContactEmail))
   330  		continue
   331  	}
   332  
   333  	return finalErr
   334  }