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

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