Salesforce Update Operations — AutoResponder Pipeline#
Purpose#
Sai Teja asked (2026-04-21):
"Salesforce: I am not clear on which Contacts will get effect. Based on my understanding, we have two types of contacts — One from Multipub and other directly captured in Salesforce. So which Contacts/Leads will get effected and how these changes reflect in Salesforce? Through API?"
Short answer:
Yes — via the Salesforce REST API (the
simple_salesforcePython client). No SQL, no API-free integration.- Two object types are touched:
ContactandLead. Multipub-sourced Contacts are skipped by the pipeline — Marketing / SFMC owns them. Non-Multipub Contacts and all Leads are processed by the pipeline.
This doc is the single source of truth for every Salesforce operation the pipeline performs.
Who gets affected, at a glance#
| Record | Update path |
|---|---|
| Lead | Always processed (inactive / reactivate / email / title) |
| Contact WITHOUT a Multipub record | Always processed — subject to the active-only CUPOLA gate for email/title updates |
| Contact WITH a Multipub record | Skipped — Marketing/SFMC handles via suppression list |
Active-only gate. Email / title updates on any Salesforce record now fall under the same active-only rule as every other system:
ActionEngine._handle_title_updateonly proceeds when an active CUPOLA row exists for the contact. Without one, the update is routed to the Human Review digest (HUMAN_REVIEW_REASON_UPDATE_ON_INACTIVEorHUMAN_REVIEW_REASON_ACTIVE_NEW_CONTACT) and no Salesforce write fires. Status flips (inactive / reactivate) remain controlled by the determination handlers; auto-reactivation of inactive CUPOLA rows on an ACTIVE outcome is disabled entirely.
The Multipub skip gate fires in four places inside
processors/action_engine.py:
_handle_inactive— status flip_handle_email_change— email update_handle_replacement— email / title update for the same-person case_handle_reactivate(INACTIVE-but-now-active ↔ ACTIVE path)
In every case the rule is:
record_type = sf_record.additional_data.get("record_type", "Contact")
is_multipub_contact = (record_type == "Contact" and multipub_record is not None)
if is_multipub_contact:
# Skip — action-log entry "Skipped (Multipub/Marketing owns this contact)"
continueWhen a Contact is skipped the action log emits an entry so Max / Erin can see why the row did not flow through.
1. Lookup (Contact + Lead)#
Source file: src/auto_responder/services/databases/salesforce.py::lookup_by_email
The pipeline hits both objects with SOQL:
-- Contact
SELECT Id, Email, Name
FROM Contact
WHERE Email = '<escaped-email>'
-- Lead
SELECT Id, Email, Name, Status
FROM Lead
WHERE Email = '<escaped-email>'Both result sets are collapsed into Contact.additional_data["record_type"]
so the action engine can route per-type downstream. The Contact is_active
attribute is always None (the Contact object has no native IsActive
field); Lead is_active is derived from `Status not in {"Disqualified",
"Inactive", ""}`.
2. Mark inactive#
2a. Contact (non-Multipub only)#
Source file: src/auto_responder/services/databases/salesforce.py::update_contact_status
sf.Contact.update(contact_id, {"Contact_Status__c": "Inactive"})The target field is the custom picklist Contact_Status__c (values
"Active" / "Inactive"). The standard IsActive Contact field is not
used — most Salesforce orgs treat IsActive as opportunity-team-only.
2b. Lead#
Source file: src/auto_responder/services/databases/salesforce.py::update_lead_status
sf.Lead.update(lead_id, {"Status": "Inactive"})When reactivating a Lead the pipeline writes `Status = "Open - Not Contacted"`.
3. Update email (non-Multipub Contact only)#
Source file: src/auto_responder/services/databases/salesforce.py::update_contact_email
sf.Contact.update(contact_id, {"Email": "<new-email>"})Leads follow the same logic in the action engine but through
sf.Lead.update(lead_id, {"Email": "<new-email>"}) — currently invoked via
the generic update path; we can extract a dedicated update_lead_email if
Teja wants parity with Contact.
4. Update title (non-Multipub Contact only)#
Source file: src/auto_responder/services/databases/salesforce.py::update_contact_title
sf.Contact.update(contact_id, {"Title": "<new-title>"})5. Add a new contact (rare — behind automation flag)#
Source file: src/auto_responder/services/databases/salesforce.py::add_contact
Used only when _resolve_account succeeds and the pipeline decides a new
Contact is warranted (currently reserved for the replacement auto-add path,
which is off by default — same rationale as CUPOLA Phase 7.1).
# 1) Find / create the Account
sf.query("SELECT Id FROM Account WHERE Name = '<org>' LIMIT 1")
sf.Account.create({"Name": "<org>"})
# 2) Create the Contact
sf.Contact.create({
"Email": "<email>",
"FirstName": "<first>",
"LastName": "<last>",
"Title": "<title>",
"AccountId": "<account_id>",
"Contact_Status__c": "Active",
})6. What does not touch Salesforce#
Multipub-sourced Contacts. Route is: pipeline detects
multipubrecord on the unified contact → Salesforce Contact updates for that row are logged and skipped. Marketing/SFMC gets an unsubscribe file (output_document_inactive_people.csv/output_document_email_update_requests.csv) and handles the SFMC side manually.- Opportunities, Campaigns, Campaign Members. Untouched.
- Account merges / cleanup. Not in scope for this pipeline.
- Custom objects other than
Contact_Status__c— untouched.
7. Credentials / connectivity#
Authentication supports two modes (both native to simple_salesforce):
Session-token auth (preferred).
SALESFORCE_ACCESS_TOKEN+SALESFORCE_DOMAIN(instance URL). Used in CI and when the org exposes a refreshable session ID.Username / password / security token.
SALESFORCE_USERNAME,SALESFORCE_PASSWORD,SALESFORCE_SECURITY_TOKEN,SALESFORCE_DOMAIN.
All requests flow through https://<domain>.salesforce.com/services/data/vXX.0/.
There is no direct SQL connection to Salesforce — every change above is
an authenticated HTTPS PATCH / POST to the REST API.
8. Review checklist for Teja#
[ ] Confirm the custom field
Contact_Status__cexists in the org with the "Active" / "Inactive" picklist values.[ ] Confirm Lead
Statuspicklist includes"Inactive"and"Open - Not Contacted".[ ] Confirm the Multipub-skip rule matches your expectation (Marketing owns Multipub-sourced Contacts).
[ ] Sign off on the current list of writes, or request additional fields (e.g.
LastAutoResponseDate__c,AutoResponseCategory__caudit fields).
Once confirmed we can lock the Salesforce surface in the same way the CUPOLA one is locked.