email-exporter: Use the upsert-by-email endpoint (#8297)
Prevent duplicate contacts in Pardot by using the upsert-by-email endpoint. Some background: In https://github.com/letsencrypt/boulder/pull/7998#discussion_r1949702280 (later superseded by https://github.com/letsencrypt/boulder/pull/8016), we discussed using this endpoint. It turns out I was wrong about Pardot storing Prospects by email address; you can have multiple Prospects with the same email. While subscribed newsletters won’t be sent to both contacts, the onboarding process doesn’t deduplicate in this way and will send to an email address each time it’s added, resulting in duplicate onboarding messages.
This commit is contained in:
parent
05e631593e
commit
a3eb6aa043
|
@ -18,10 +18,12 @@ 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"
|
||||
// contactsPath is the path to the Pardot v5 Prospect upsert-by-email
|
||||
// endpoint. This endpoint will create a new Prospect if one does not
|
||||
// already exist with the same email address.
|
||||
//
|
||||
// https://developer.salesforce.com/docs/marketing/pardot/guide/prospect-v5.html#prospect-upsert-by-email
|
||||
contactsPath = "/api/v5/objects/prospects/do/upsertLatestByEmail"
|
||||
|
||||
// maxAttempts is the maximum number of attempts to retry a request.
|
||||
maxAttempts = 3
|
||||
|
@ -60,7 +62,7 @@ type PardotClientImpl struct {
|
|||
businessUnit string
|
||||
clientId string
|
||||
clientSecret string
|
||||
contactsURL string
|
||||
endpointURL string
|
||||
tokenURL string
|
||||
token *oAuthToken
|
||||
clk clock.Clock
|
||||
|
@ -70,7 +72,7 @@ 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)
|
||||
endpointURL, err := url.JoinPath(pardotBaseURL, contactsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to join contacts path: %w", err)
|
||||
}
|
||||
|
@ -83,7 +85,7 @@ func NewPardotClientImpl(clk clock.Clock, businessUnit, clientId, clientSecret,
|
|||
businessUnit: businessUnit,
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
contactsURL: contactsURL,
|
||||
endpointURL: endpointURL,
|
||||
tokenURL: tokenURL,
|
||||
token: &oAuthToken{},
|
||||
clk: clk,
|
||||
|
@ -140,6 +142,19 @@ func redactEmail(body []byte, email string) string {
|
|||
return string(bytes.ReplaceAll(body, []byte(email), []byte("[REDACTED]")))
|
||||
}
|
||||
|
||||
type prospect struct {
|
||||
// Email is the email address of the prospect.
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type upsertPayload struct {
|
||||
// MatchEmail is the email address to match against existing prospects to
|
||||
// avoid adding duplicates.
|
||||
MatchEmail string `json:"matchEmail"`
|
||||
// Prospect is the prospect data to be upserted.
|
||||
Prospect prospect `json:"prospect"`
|
||||
}
|
||||
|
||||
// SendContact submits an email to the Pardot Contacts endpoint, retrying up
|
||||
// to 3 times with exponential backoff.
|
||||
func (pc *PardotClientImpl) SendContact(email string) error {
|
||||
|
@ -156,7 +171,10 @@ func (pc *PardotClientImpl) SendContact(email string) error {
|
|||
return fmt.Errorf("failed to update token: %w", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{"email": email})
|
||||
payload, err := json.Marshal(upsertPayload{
|
||||
MatchEmail: email,
|
||||
Prospect: prospect{Email: email},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
@ -165,7 +183,7 @@ func (pc *PardotClientImpl) SendContact(email string) error {
|
|||
for attempt := range maxAttempts {
|
||||
time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
|
||||
|
||||
req, err := http.NewRequest("POST", pc.contactsURL, bytes.NewReader(payload))
|
||||
req, err := http.NewRequest("POST", pc.endpointURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
finalErr = fmt.Errorf("failed to create new contact request: %w", err)
|
||||
continue
|
||||
|
|
|
@ -85,7 +85,7 @@ func (ts *testServer) checkToken(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (ts *testServer) createContactsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (ts *testServer) upsertContactsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ts.checkToken(w, r)
|
||||
|
||||
businessUnitId := r.Header.Get("Pardot-Business-Unit-Id")
|
||||
|
@ -100,19 +100,22 @@ func (ts *testServer) createContactsHandler(w http.ResponseWriter, r *http.Reque
|
|||
return
|
||||
}
|
||||
|
||||
type contactData struct {
|
||||
Email string `json:"email"`
|
||||
type upsertPayload struct {
|
||||
MatchEmail string `json:"matchEmail"`
|
||||
Prospect struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"prospect"`
|
||||
}
|
||||
|
||||
var contact contactData
|
||||
err = json.Unmarshal(body, &contact)
|
||||
var payload upsertPayload
|
||||
err = json.Unmarshal(body, &payload)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if contact.Email == "" {
|
||||
http.Error(w, "Missing 'email' field in request body", http.StatusBadRequest)
|
||||
if payload.MatchEmail == "" || payload.Prospect.Email == "" {
|
||||
http.Error(w, "Missing 'matchEmail' or 'prospect.email' in request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -122,7 +125,7 @@ func (ts *testServer) createContactsHandler(w http.ResponseWriter, r *http.Reque
|
|||
// with a small number of contacts, so it's fine.
|
||||
ts.contacts.created = ts.contacts.created[1:]
|
||||
}
|
||||
ts.contacts.created = append(ts.contacts.created, contact.Email)
|
||||
ts.contacts.created = append(ts.contacts.created, payload.Prospect.Email)
|
||||
ts.contacts.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
@ -198,7 +201,7 @@ func main() {
|
|||
|
||||
// Pardot API Server
|
||||
pardotMux := http.NewServeMux()
|
||||
pardotMux.HandleFunc("/api/v5/objects/prospects", ts.createContactsHandler)
|
||||
pardotMux.HandleFunc("/api/v5/objects/prospects/do/upsertLatestByEmail", ts.upsertContactsHandler)
|
||||
pardotMux.HandleFunc("/contacts", ts.queryContactsHandler)
|
||||
|
||||
pardotServer := &http.Server{
|
||||
|
|
Loading…
Reference in New Issue