200 lines
5.5 KiB
Go
200 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
|
|
}
|