SfSkills Domains Docs GitHub

Home / data / case-history-migration

case-history-migration

Use when migrating historical Case records — including CaseComments, EmailMessages, email attachments, and related files — from a legacy system into Salesforce Service Cloud. Covers strict object load order, EmailMessage Status='3' permanent locking, ContentDocumentLink Bulk API limitation, CaseHistory non-insertable constraint, and EmailMessageRelation setup. NOT for archiving or purging Case data already in the org — use data/service-data-archival. NOT for generic multi-object migration planning and rollback — use data/data-migration-planning.

Open the skill folder on GitHub


Case History Migration

This skill activates when a practitioner needs to migrate historical Case records — including comments, inbound and outbound emails, email attachments, and associated files — from a legacy help desk or CRM system into Salesforce Service Cloud. It covers the mandatory object load order enforced by referential integrity, the permanent read-only lock triggered by EmailMessage Status='3', the Bulk API batching restriction on ContentDocumentLink, and the non-insertable nature of the CaseHistory object.


Before Starting

Gather this context before working on anything in this domain:


Core Concepts

Load Sequence: Strict Dependency Order

Every object in the Case email migration chain has a parent that must exist first. The required sequence is:

  1. Case — the root record. All other objects require a valid ParentId or LinkedEntityId pointing to a Case record.
  2. CaseComment — requires a valid ParentId (Case). Comments must be loaded before emails if any email references a comment thread.
  3. EmailMessage — requires a valid ParentId (Case). Status must be set carefully (see Core Concepts: EmailMessage Status Locking).
  4. EmailMessageRelation — requires a valid EmailMessageId. Load after all EmailMessage records are confirmed inserted.
  5. ContentVersion — the file content record. Load file binary content here. ContentVersion auto-creates a parent ContentDocument on insert. Does not require a Case to exist yet.
  6. ContentDocumentLink — the junction object linking a ContentDocument (created from ContentVersion) to a Case or EmailMessage. Requires both ContentDocumentId (from ContentVersion's auto-created parent) and LinkedEntityId (Case or EmailMessage). Must be loaded via REST/SOAP API — not Bulk API 2.0.

Violating this order causes referential integrity failures that appear as row-level errors in the Bulk API job results and are time-consuming to diagnose when mixed in large batches.

EmailMessage Status Field and Permanent Locking

The Status field on EmailMessage controls the read/write state of the record:

Status ValueLabelRead-Only After Insert?
0NewNo — record remains editable
1ReadNo — record remains editable
2RepliedNo — record remains editable
3SentYes — permanently locked, no updates or deletes possible
4ForwardedNo — record remains editable

Setting Status='3' (Sent) at insert time is intended for Salesforce's native outbound email sending workflow, where it signals that the message has been delivered. When set during a data migration load, it permanently freezes the record. There is no API override, no permission set, and no org configuration that unlocks a Status='3' EmailMessage. The only remediation is to delete and re-insert (which is also blocked — Status='3' records cannot be deleted by the API or UI).

Correct approach for migration: Load all inbound emails with Status='1' (Read). For outbound emails that should appear as sent, use Status='2' (Replied) or Status='4' (Forwarded) as appropriate. Reserve Status='3' only if the business explicitly accepts permanent locking and will never need to modify those records.

Files in Salesforce are stored as:

ContentVersion supports Bulk API 2.0 and can be loaded in large batches. ContentDocumentLink does not support Bulk API 2.0. Bulk API jobs that include ContentDocumentLink rows will fail with OPERATION_TOO_LARGE or process silently without creating links.

For large file volumes, use a Python or Node.js script that issues REST API POST /services/data/vXX.X/sobjects/ContentDocumentLink calls in serial or small parallel batches. Respect the per-org API limit (typically 15,000 REST calls per 24 hours for Developer Edition; higher for Enterprise/Unlimited).

After inserting a ContentVersion, query SELECT ContentDocumentId FROM ContentVersion WHERE Id = '<inserted_id>' to resolve the auto-created ContentDocument Id before inserting the ContentDocumentLink.

CaseHistory: Non-Insertable System Object

CaseHistory is a child object of Case that Salesforce generates automatically when tracked fields on a Case record are changed. It captures the old value, new value, field name, and timestamp of the change. It is read-only via the API — sObject not insertable is returned for any insert attempt.

The practical consequence for migrations: historical case audit trails (status changes, owner reassignments, priority escalations from the legacy system) cannot be brought into CaseHistory natively. Options:


Common Patterns

Pattern: Full Case Email and Attachment Migration

When to use: The source system has a complete case history including inbound and outbound email threads with file attachments. The business requires the full email timeline in Salesforce.

How it works:

  1. Export Cases from source. Map to Salesforce Case fields. Assign a Legacy_Case_Id__c external ID field. Upsert Cases via Bulk API 2.0.
  2. Export CaseComments. Map ParentId using Case.Legacy_Case_Id__c cross-reference. Load via Bulk API 2.0.
  3. Export EmailMessages. Set Status='1' for inbound emails and Status='2' for outbound emails (never Status='3' at load time). Map ParentId via cross-reference. Include MessageDate from source to preserve the original email timestamp. Load via Bulk API 2.0.
  4. Export email addressees. For each email, create EmailMessageRelation rows: one for each To/CC/BCC recipient that maps to a Contact, Lead, or User. Set RelationObjectType and RelationId appropriately. Load via Bulk API 2.0.
  5. Export file attachments. Create ContentVersion rows with Title, PathOnClient, VersionData (base64-encoded), and FirstPublishLocationId set to the parent Case Id (optional — you can also link via ContentDocumentLink). Load ContentVersion via Bulk API 2.0.
  6. For each ContentVersion inserted, query its auto-created ContentDocumentId. Build ContentDocumentLink rows linking to the Case or EmailMessage. Insert via REST API (not Bulk API) in batches of 200.

Why not use Bulk API for ContentDocumentLink: Bulk API 2.0 does not support ContentDocumentLink. Using it produces silent failures or explicit job errors without useful row-level diagnostics.

Pattern: Comments-Only Case Migration (No Emails)

When to use: The source system used a comment/note model rather than email threading. No actual email messages need to be migrated — only case notes and internal comments.

How it works:

  1. Upsert Cases with external IDs.
  2. Load CaseComments with IsPublished = true for customer-visible comments and IsPublished = false for internal notes.
  3. For attachments on comments, load ContentVersion first, then link via ContentDocumentLink to the Case using the REST API.
  4. Skip EmailMessage, EmailMessageRelation, and any email-specific steps.

Why not use Attachment object: The legacy Attachment object (linked to Case via ParentId) is deprecated in favor of Files (ContentVersion/ContentDocument/ContentDocumentLink). Salesforce recommends against loading new Attachment records; use ContentVersion instead.


Decision Guidance

SituationRecommended ApproachReason
Loading outbound emails that should appear as sentUse Status='2' (Replied) at load timeStatus='3' permanently locks — cannot be updated or deleted after insert
Files attached to casesContentVersion → ContentDocumentLink via REST APIContentDocumentLink does not support Bulk API 2.0
Historical status/owner changes need to be preservedTask records per change event on the CaseCaseHistory is not insertable; Tasks appear in activity timeline
Email recipients need to be linked to Contact recordsLoad EmailMessageRelation after EmailMessageToAddress/FromAddress strings do not auto-link to Contact records
High volume of file attachments (10,000+)Script REST API calls for ContentDocumentLink in parallel threadsREST API rate limits apply; plan for multi-day load windows or request a limit increase
Source emails have inline imagesEmbed as ContentVersion, link with ContentDocumentLink ShareType='I'Inline images are ContentDocuments with ShareType Internal
Source system had threaded email repliesLink all reply messages to the same Case ParentId; use ReplyToEmailMessageId to chain themEmailMessage supports a ReplyToEmailMessageId field for threading

Step-by-step instructions for an AI agent or practitioner activating this skill:

  1. Confirm prerequisites and org configuration: Verify Email-to-Case is enabled if inbound emails are being migrated. Confirm external ID fields exist on Case (Legacy_Case_Id__c). Check API limits and ContentDocumentLink volume estimate to plan the REST API load window. Map all source user/agent IDs to Salesforce UserIds.
  2. Load Case records first: Upsert Cases via Bulk API 2.0 using Legacy_Case_Id__c as the external ID. Verify row counts and spot-check field values before proceeding to child objects.
  3. Load CaseComments: Use cross-reference notation (Case.Legacy_Case_Id__c) for ParentId to avoid pre-resolving Salesforce IDs. Set IsPublished correctly for customer-visible vs. internal comments. Load via Bulk API 2.0.
  4. Load EmailMessages with correct Status: Set Status='1' for inbound, Status='2' for outbound. Never set Status='3' at load time. Include MessageDate from source. Load via Bulk API 2.0.
  5. Load EmailMessageRelation records: For each email, create relation rows for To, CC, BCC addressees that map to Contact, Lead, or User records. Load after EmailMessage is confirmed complete.
  6. Load ContentVersion (file content): Load all file content via Bulk API 2.0. After each batch, query ContentDocumentId from the inserted ContentVersion records.
  7. Link files via ContentDocumentLink using REST API: Build ContentDocumentLink rows from the resolved ContentDocumentId values and the target Case or EmailMessage Ids. Insert via REST API in serial batches of 200. Validate attachment count per case matches source.

Review Checklist

Run through these before marking case history migration complete:


Salesforce-Specific Gotchas

Non-obvious platform behaviors that cause real production problems:

  1. EmailMessage Status='3' permanently locks the record — Setting Status='3' (Sent) at insert time makes the EmailMessage record permanently read-only. It cannot be updated or deleted via UI, Apex, REST, SOAP, or Bulk API — by any user including System Administrator. The only recovery is a destructive re-migration: delete the EmailMessage (impossible via API once locked) using Salesforce Data Export and a special support case, or leave the locked records and re-insert corrected copies. Use Status='1' (Read) or Status='2' (Replied) for historical emails.
  2. ContentDocumentLink does not support Bulk API 2.0 — Any Bulk API 2.0 job that includes ContentDocumentLink rows will either fail immediately or process silently without creating links. This causes phantom attachment failures where ContentVersion records exist and ContentDocuments are created, but no files appear on the Case record. Always use the standard REST or SOAP API for ContentDocumentLink inserts.
  3. CaseHistory is not insertable — CaseHistory is a system-generated read-only object. API calls that attempt to insert CaseHistory records return INVALID_TYPE_FOR_OPERATION: entity type Case History does not support create. Historical case audit data must be stored in Task records, FeedItem records, or a custom object.
  4. EmailMessageRelation is required for Contact linkage — The ToAddress and FromAddress fields on EmailMessage are plain text strings. They do not automatically resolve to Contact or Lead records. Without explicit EmailMessageRelation rows, the email appears in the case timeline but the associated Contact is not linked. Reports that rely on email-to-contact associations will return incomplete data.
  5. ContentVersion auto-creates ContentDocument — Id not available until after insert — When a ContentVersion is inserted, Salesforce automatically creates a parent ContentDocument and populates ContentVersion.ContentDocumentId. This Id is not known before the insert, so ContentDocumentLink rows cannot be pre-built in the same batch. The workflow must be: insert ContentVersion → query ContentDocumentId → build ContentDocumentLink rows → insert ContentDocumentLink via REST API.

Output Artifacts

ArtifactDescription
case-history-migration-template.mdFill-in-the-blank work template capturing load sequence decisions, external ID mapping, EmailMessage Status strategy, ContentDocumentLink volume estimate, and post-migration validation plan
check_case_history_migration.pystdlib Python checker that validates migration CSVs for common sequencing violations, EmailMessage Status='3' usage, and ContentDocumentLink Bulk API misuse


Official Sources Used

Package files