199 lines
		
	
	
		
			5.5 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			199 lines
		
	
	
		
			5.5 KiB
		
	
	
	
		
			Go
		
	
	
	
| package email
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"encoding/json"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"net/http"
 | |
| 	"net/url"
 | |
| 	"sync"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/jmhodges/clock"
 | |
| 	"github.com/letsencrypt/boulder/core"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	// tokenPath is the path to the Salesforce OAuth2 token endpoint.
 | |
| 	tokenPath = "/services/oauth2/token"
 | |
| 
 | |
| 	// contactsPath is the path to the Pardot v5 Prospects endpoint. This
 | |
| 	// endpoint will create a new Prospect if one does not already exist with
 | |
| 	// the same email address.
 | |
| 	contactsPath = "/api/v5/objects/prospects"
 | |
| 
 | |
| 	// maxAttempts is the maximum number of attempts to retry a request.
 | |
| 	maxAttempts = 3
 | |
| 
 | |
| 	// retryBackoffBase is the base for exponential backoff.
 | |
| 	retryBackoffBase = 2.0
 | |
| 
 | |
| 	// retryBackoffMax is the maximum backoff time.
 | |
| 	retryBackoffMax = 10 * time.Second
 | |
| 
 | |
| 	// retryBackoffMin is the minimum backoff time.
 | |
| 	retryBackoffMin = 200 * time.Millisecond
 | |
| 
 | |
| 	// tokenExpirationBuffer is the time before the token expires that we will
 | |
| 	// attempt to refresh it.
 | |
| 	tokenExpirationBuffer = 5 * time.Minute
 | |
| )
 | |
| 
 | |
| // PardotClient is an interface for interacting with Pardot. It exists to
 | |
| // facilitate testing mocks.
 | |
| type PardotClient interface {
 | |
| 	SendContact(email string) error
 | |
| }
 | |
| 
 | |
| // oAuthToken holds the OAuth2 access token and its expiration.
 | |
| type oAuthToken struct {
 | |
| 	sync.Mutex
 | |
| 
 | |
| 	accessToken string
 | |
| 	expiresAt   time.Time
 | |
| }
 | |
| 
 | |
| // PardotClientImpl handles authentication and sending contacts to Pardot. It
 | |
| // implements the PardotClient interface.
 | |
| type PardotClientImpl struct {
 | |
| 	businessUnit string
 | |
| 	clientId     string
 | |
| 	clientSecret string
 | |
| 	contactsURL  string
 | |
| 	tokenURL     string
 | |
| 	token        *oAuthToken
 | |
| 	clk          clock.Clock
 | |
| }
 | |
| 
 | |
| var _ PardotClient = &PardotClientImpl{}
 | |
| 
 | |
| // NewPardotClientImpl creates a new PardotClientImpl.
 | |
| func NewPardotClientImpl(clk clock.Clock, businessUnit, clientId, clientSecret, oauthbaseURL, pardotBaseURL string) (*PardotClientImpl, error) {
 | |
| 	contactsURL, err := url.JoinPath(pardotBaseURL, contactsPath)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("failed to join contacts path: %w", err)
 | |
| 	}
 | |
| 	tokenURL, err := url.JoinPath(oauthbaseURL, tokenPath)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("failed to join token path: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return &PardotClientImpl{
 | |
| 		businessUnit: businessUnit,
 | |
| 		clientId:     clientId,
 | |
| 		clientSecret: clientSecret,
 | |
| 		contactsURL:  contactsURL,
 | |
| 		tokenURL:     tokenURL,
 | |
| 		token:        &oAuthToken{},
 | |
| 		clk:          clk,
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| type oauthTokenResp struct {
 | |
| 	AccessToken string `json:"access_token"`
 | |
| 	ExpiresIn   int    `json:"expires_in"`
 | |
| }
 | |
| 
 | |
| // updateToken updates the OAuth token if necessary.
 | |
| func (pc *PardotClientImpl) updateToken() error {
 | |
| 	pc.token.Lock()
 | |
| 	defer pc.token.Unlock()
 | |
| 
 | |
| 	now := pc.clk.Now()
 | |
| 	if now.Before(pc.token.expiresAt.Add(-tokenExpirationBuffer)) && pc.token.accessToken != "" {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	resp, err := http.PostForm(pc.tokenURL, url.Values{
 | |
| 		"grant_type":    {"client_credentials"},
 | |
| 		"client_id":     {pc.clientId},
 | |
| 		"client_secret": {pc.clientSecret},
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to retrieve token: %w", err)
 | |
| 	}
 | |
| 	defer resp.Body.Close()
 | |
| 
 | |
| 	if resp.StatusCode != http.StatusOK {
 | |
| 		body, readErr := io.ReadAll(resp.Body)
 | |
| 		if readErr != nil {
 | |
| 			return fmt.Errorf("token request failed with status %d; while reading body: %w", resp.StatusCode, readErr)
 | |
| 		}
 | |
| 		return fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, body)
 | |
| 	}
 | |
| 
 | |
| 	var respJSON oauthTokenResp
 | |
| 	err = json.NewDecoder(resp.Body).Decode(&respJSON)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to decode token response: %w", err)
 | |
| 	}
 | |
| 	pc.token.accessToken = respJSON.AccessToken
 | |
| 	pc.token.expiresAt = pc.clk.Now().Add(time.Duration(respJSON.ExpiresIn) * time.Second)
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // redactEmail replaces all occurrences of an email address in a response body
 | |
| // with "[REDACTED]".
 | |
| func redactEmail(body []byte, email string) string {
 | |
| 	return string(bytes.ReplaceAll(body, []byte(email), []byte("[REDACTED]")))
 | |
| }
 | |
| 
 | |
| // SendContact submits an email to the Pardot Contacts endpoint, retrying up
 | |
| // to 3 times with exponential backoff.
 | |
| func (pc *PardotClientImpl) SendContact(email string) error {
 | |
| 	var err error
 | |
| 	for attempt := range maxAttempts {
 | |
| 		time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
 | |
| 		err = pc.updateToken()
 | |
| 		if err != nil {
 | |
| 			continue
 | |
| 		}
 | |
| 		break
 | |
| 	}
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to update token: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	payload, err := json.Marshal(map[string]string{"email": email})
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to marshal payload: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	var finalErr error
 | |
| 	for attempt := range maxAttempts {
 | |
| 		time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
 | |
| 
 | |
| 		req, err := http.NewRequest("POST", pc.contactsURL, bytes.NewReader(payload))
 | |
| 		if err != nil {
 | |
| 			finalErr = fmt.Errorf("failed to create new contact request: %w", err)
 | |
| 			continue
 | |
| 		}
 | |
| 		req.Header.Set("Content-Type", "application/json")
 | |
| 		req.Header.Set("Authorization", "Bearer "+pc.token.accessToken)
 | |
| 		req.Header.Set("Pardot-Business-Unit-Id", pc.businessUnit)
 | |
| 
 | |
| 		resp, err := http.DefaultClient.Do(req)
 | |
| 		if err != nil {
 | |
| 			finalErr = fmt.Errorf("create contact request failed: %w", err)
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		defer resp.Body.Close()
 | |
| 		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
 | |
| 			return nil
 | |
| 		}
 | |
| 
 | |
| 		body, err := io.ReadAll(resp.Body)
 | |
| 		if err != nil {
 | |
| 			finalErr = fmt.Errorf("create contact request returned status %d; while reading body: %w", resp.StatusCode, err)
 | |
| 			continue
 | |
| 		}
 | |
| 		finalErr = fmt.Errorf("create contact request returned status %d: %s", resp.StatusCode, redactEmail(body, email))
 | |
| 		continue
 | |
| 	}
 | |
| 
 | |
| 	return finalErr
 | |
| }
 |