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 }