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:
Samantha Frank 2025-07-07 19:55:30 -04:00 committed by GitHub
parent 05e631593e
commit a3eb6aa043
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 39 additions and 18 deletions

View File

@ -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

View File

@ -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{