DocsAutoResponderProcess

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:

This doc is the single source of truth for every Salesforce operation the pipeline performs.

Who gets affected, at a glance#

RecordUpdate path
LeadAlways processed (inactive / reactivate / email / title)
Contact WITHOUT a Multipub recordAlways processed — subject to the active-only CUPOLA gate for email/title updates
Contact WITH a Multipub recordSkipped — 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_update only 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_INACTIVE or HUMAN_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:

In every case the rule is:

python
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)"
    continue

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

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

python
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

python
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

python
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

python
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).

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

7. Credentials / connectivity#

Authentication supports two modes (both native to simple_salesforce):

  1. Session-token auth (preferred). SALESFORCE_ACCESS_TOKEN + SALESFORCE_DOMAIN (instance URL). Used in CI and when the org exposes a refreshable session ID.

  2. 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#

Once confirmed we can lock the Salesforce surface in the same way the CUPOLA one is locked.