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 is the path to the Salesforce OAuth2 token endpoint.
|
||||||
tokenPath = "/services/oauth2/token"
|
tokenPath = "/services/oauth2/token"
|
||||||
|
|
||||||
// contactsPath is the path to the Pardot v5 Prospects endpoint. This
|
// contactsPath is the path to the Pardot v5 Prospect upsert-by-email
|
||||||
// endpoint will create a new Prospect if one does not already exist with
|
// endpoint. This endpoint will create a new Prospect if one does not
|
||||||
// the same email address.
|
// already exist with the same email address.
|
||||||
contactsPath = "/api/v5/objects/prospects"
|
//
|
||||||
|
// 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 is the maximum number of attempts to retry a request.
|
||||||
maxAttempts = 3
|
maxAttempts = 3
|
||||||
|
@ -60,7 +62,7 @@ type PardotClientImpl struct {
|
||||||
businessUnit string
|
businessUnit string
|
||||||
clientId string
|
clientId string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
contactsURL string
|
endpointURL string
|
||||||
tokenURL string
|
tokenURL string
|
||||||
token *oAuthToken
|
token *oAuthToken
|
||||||
clk clock.Clock
|
clk clock.Clock
|
||||||
|
@ -70,7 +72,7 @@ var _ PardotClient = &PardotClientImpl{}
|
||||||
|
|
||||||
// NewPardotClientImpl creates a new PardotClientImpl.
|
// NewPardotClientImpl creates a new PardotClientImpl.
|
||||||
func NewPardotClientImpl(clk clock.Clock, businessUnit, clientId, clientSecret, oauthbaseURL, pardotBaseURL string) (*PardotClientImpl, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to join contacts path: %w", err)
|
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,
|
businessUnit: businessUnit,
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
contactsURL: contactsURL,
|
endpointURL: endpointURL,
|
||||||
tokenURL: tokenURL,
|
tokenURL: tokenURL,
|
||||||
token: &oAuthToken{},
|
token: &oAuthToken{},
|
||||||
clk: clk,
|
clk: clk,
|
||||||
|
@ -140,6 +142,19 @@ func redactEmail(body []byte, email string) string {
|
||||||
return string(bytes.ReplaceAll(body, []byte(email), []byte("[REDACTED]")))
|
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
|
// SendContact submits an email to the Pardot Contacts endpoint, retrying up
|
||||||
// to 3 times with exponential backoff.
|
// to 3 times with exponential backoff.
|
||||||
func (pc *PardotClientImpl) SendContact(email string) error {
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -165,7 +183,7 @@ func (pc *PardotClientImpl) SendContact(email string) error {
|
||||||
for attempt := range maxAttempts {
|
for attempt := range maxAttempts {
|
||||||
time.Sleep(core.RetryBackoff(attempt, retryBackoffMin, retryBackoffMax, retryBackoffBase))
|
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 {
|
if err != nil {
|
||||||
finalErr = fmt.Errorf("failed to create new contact request: %w", err)
|
finalErr = fmt.Errorf("failed to create new contact request: %w", err)
|
||||||
continue
|
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)
|
ts.checkToken(w, r)
|
||||||
|
|
||||||
businessUnitId := r.Header.Get("Pardot-Business-Unit-Id")
|
businessUnitId := r.Header.Get("Pardot-Business-Unit-Id")
|
||||||
|
@ -100,19 +100,22 @@ func (ts *testServer) createContactsHandler(w http.ResponseWriter, r *http.Reque
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type contactData struct {
|
type upsertPayload struct {
|
||||||
|
MatchEmail string `json:"matchEmail"`
|
||||||
|
Prospect struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
} `json:"prospect"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var contact contactData
|
var payload upsertPayload
|
||||||
err = json.Unmarshal(body, &contact)
|
err = json.Unmarshal(body, &payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
|
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if contact.Email == "" {
|
if payload.MatchEmail == "" || payload.Prospect.Email == "" {
|
||||||
http.Error(w, "Missing 'email' field in request body", http.StatusBadRequest)
|
http.Error(w, "Missing 'matchEmail' or 'prospect.email' in request body", http.StatusBadRequest)
|
||||||
return
|
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.
|
// with a small number of contacts, so it's fine.
|
||||||
ts.contacts.created = ts.contacts.created[1:]
|
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()
|
ts.contacts.Unlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
@ -198,7 +201,7 @@ func main() {
|
||||||
|
|
||||||
// Pardot API Server
|
// Pardot API Server
|
||||||
pardotMux := http.NewServeMux()
|
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)
|
pardotMux.HandleFunc("/contacts", ts.queryContactsHandler)
|
||||||
|
|
||||||
pardotServer := &http.Server{
|
pardotServer := &http.Server{
|
||||||
|
|
Loading…
Reference in New Issue