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:
- CaseHistory is not insertable: The
CaseHistoryobject records field-level changes on Case (e.g., Status, Priority, Owner changes) and is system-generated by Salesforce. It cannot be inserted via API. Attempts to directly insert CaseHistory records returnINVALID_TYPE_FOR_OPERATION. Historical audit data must be approximated via Task records or FeedItem entries on the Case. - EmailMessage Status='3' permanently locks the record: Setting
Statusto'3'(Sent) on an EmailMessage record at insert time marks the record as a sent outbound email and makes it permanently read-only — it cannot be updated or deleted by any user or API caller, including System Administrator and Bulk API. This is the most common destructive mistake in Case email migrations. Always load EmailMessage records withStatus='0'(New) orStatus='1'(Read) unless you explicitly need the permanent locking behavior. - ContentDocumentLink does not support Bulk API 2.0 batching: ContentDocumentLink (the junction object that links a ContentDocument/file to a Case, EmailMessage, or other record) must be inserted via the standard REST API or SOAP API. Bulk API 2.0 jobs that include ContentDocumentLink will fail or silently drop rows. Use single-record REST API calls or small SOAP API batches (200 rows max) for ContentDocumentLink inserts.
- EmailMessageRelation is required for addressee linkage: EmailMessage records store raw address strings in
ToAddress,FromAddress,CcAddress, andBccAddress, but those strings are not automatically linked to Contact, Lead, or User records.EmailMessageRelationis the junction object that creates the explicit relationship between an EmailMessage and a Salesforce Contact/Lead/User. Without EmailMessageRelation rows, the email displays in the case timeline but the Contact is not linked and email-to-contact reporting is broken. - Strict load order is mandatory: Salesforce referential integrity requires: Case → CaseComment → EmailMessage → EmailMessageRelation → ContentVersion → ContentDocumentLink. Each object depends on its parent existing before insert.
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:
- Case — the root record. All other objects require a valid
ParentIdorLinkedEntityIdpointing to a Case record. - CaseComment — requires a valid
ParentId(Case). Comments must be loaded before emails if any email references a comment thread. - EmailMessage — requires a valid
ParentId(Case).Statusmust be set carefully (see Core Concepts: EmailMessage Status Locking). - EmailMessageRelation — requires a valid
EmailMessageId. Load after all EmailMessage records are confirmed inserted. - ContentVersion — the file content record. Load file binary content here.
ContentVersionauto-creates a parentContentDocumenton insert. Does not require a Case to exist yet. - ContentDocumentLink — the junction object linking a ContentDocument (created from ContentVersion) to a Case or EmailMessage. Requires both
ContentDocumentId(from ContentVersion's auto-created parent) andLinkedEntityId(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 Value | Label | Read-Only After Insert? |
|---|---|---|
| 0 | New | No — record remains editable |
| 1 | Read | No — record remains editable |
| 2 | Replied | No — record remains editable |
| 3 | Sent | Yes — permanently locked, no updates or deletes possible |
| 4 | Forwarded | No — 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.
ContentDocumentLink and the Bulk API Restriction
Files in Salesforce are stored as:
- ContentVersion — the versioned file content (body, title, MIME type, path on client)
- ContentDocument — the parent document record, auto-created when a ContentVersion is inserted
- ContentDocumentLink — the junction linking a ContentDocument to a record (Case, EmailMessage, Account, etc.)
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:
- Task records (recommended): For each historical field change event in the source export, create a Task on the Case (
WhatId= Case Id,Subject= "Historical Change: Status New → In Progress",ActivityDate= change date,Status= "Completed"). Tasks are visible in the Case activity timeline. - FeedItem (Chatter posts): Insert FeedItem records with
ParentId= Case Id andBody= the historical change summary. These appear in the Case feed and are searchable via SOSL. - Custom object: Load into a custom object (e.g.,
Case_Audit_History__c) with a lookup to Case for structured reporting that does not depend on the activity timeline.
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:
- Export Cases from source. Map to Salesforce Case fields. Assign a
Legacy_Case_Id__cexternal ID field. Upsert Cases via Bulk API 2.0. - Export CaseComments. Map
ParentIdusingCase.Legacy_Case_Id__ccross-reference. Load via Bulk API 2.0. - Export EmailMessages. Set
Status='1'for inbound emails andStatus='2'for outbound emails (neverStatus='3'at load time). MapParentIdvia cross-reference. IncludeMessageDatefrom source to preserve the original email timestamp. Load via Bulk API 2.0. - 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
RelationObjectTypeandRelationIdappropriately. Load via Bulk API 2.0. - Export file attachments. Create ContentVersion rows with
Title,PathOnClient,VersionData(base64-encoded), andFirstPublishLocationIdset to the parent Case Id (optional — you can also link via ContentDocumentLink). Load ContentVersion via Bulk API 2.0. - 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:
- Upsert Cases with external IDs.
- Load CaseComments with
IsPublished = truefor customer-visible comments andIsPublished = falsefor internal notes. - For attachments on comments, load ContentVersion first, then link via ContentDocumentLink to the Case using the REST API.
- 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
| Situation | Recommended Approach | Reason |
|---|---|---|
| Loading outbound emails that should appear as sent | Use Status='2' (Replied) at load time |
Status='3' permanently locks — cannot be updated or deleted after insert |
| Files attached to cases | ContentVersion → ContentDocumentLink via REST API | ContentDocumentLink does not support Bulk API 2.0 |
| Historical status/owner changes need to be preserved | Task records per change event on the Case | CaseHistory is not insertable; Tasks appear in activity timeline |
| Email recipients need to be linked to Contact records | Load EmailMessageRelation after EmailMessage | ToAddress/FromAddress strings do not auto-link to Contact records |
| High volume of file attachments (10,000+) | Script REST API calls for ContentDocumentLink in parallel threads | REST API rate limits apply; plan for multi-day load windows or request a limit increase |
| Source emails have inline images | Embed as ContentVersion, link with ContentDocumentLink ShareType='I' |
Inline images are ContentDocuments with ShareType Internal |
| Source system had threaded email replies | Link all reply messages to the same Case ParentId; use ReplyToEmailMessageId to chain them |
EmailMessage supports a ReplyToEmailMessageId field for threading |
Recommended Workflow
Step-by-step instructions for an AI agent or practitioner activating this skill:
- 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. - Load Case records first: Upsert Cases via Bulk API 2.0 using
Legacy_Case_Id__cas the external ID. Verify row counts and spot-check field values before proceeding to child objects. - Load CaseComments: Use cross-reference notation (
Case.Legacy_Case_Id__c) forParentIdto avoid pre-resolving Salesforce IDs. SetIsPublishedcorrectly for customer-visible vs. internal comments. Load via Bulk API 2.0. - Load EmailMessages with correct Status: Set
Status='1'for inbound,Status='2'for outbound. Never setStatus='3'at load time. IncludeMessageDatefrom source. Load via Bulk API 2.0. - 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.
- Load ContentVersion (file content): Load all file content via Bulk API 2.0. After each batch, query
ContentDocumentIdfrom the inserted ContentVersion records. - 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:
- All Cases loaded and row counts verified before any child object loads begin
- EmailMessage
Statusfield value confirmed — noStatus='3'used at load time - EmailMessageRelation rows exist for all To/CC/BCC addressees that map to Contact or User records
- CaseHistory insert was NOT attempted directly (use Task records or FeedItem instead)
- ContentVersion loaded via Bulk API 2.0 — ContentDocumentLink loaded via REST API only
- ContentDocumentId resolved from ContentVersion before ContentDocumentLink rows are built
- ContentDocumentLink row count per case matches source attachment count
- CaseComment IsPublished flag set correctly for customer-visible vs. internal comments
- Post-migration validation: case comment counts, email counts, and attachment linkage spot-checked against source
Salesforce-Specific Gotchas
Non-obvious platform behaviors that cause real production problems:
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. UseStatus='1'(Read) orStatus='2'(Replied) for historical emails.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.
CaseHistory is not insertable —
CaseHistoryis a system-generated read-only object. API calls that attempt to insert CaseHistory records returnINVALID_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.EmailMessageRelation is required for Contact linkage — The
ToAddressandFromAddressfields on EmailMessage are plain text strings. They do not automatically resolve to Contact or Lead records. Without explicitEmailMessageRelationrows, 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.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
| Artifact | Description |
|---|---|
case-history-migration-template.md |
Fill-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.py |
stdlib Python checker that validates migration CSVs for common sequencing violations, EmailMessage Status='3' usage, and ContentDocumentLink Bulk API misuse |
Related Skills
data-migration-planning— use for multi-object migration architecture, tool selection, external ID strategy, validation rule bypass, and rollback planning; this skill focuses specifically on the Case object familybulk-api-and-large-data-loads— use when load volume exceeds standard limits or when optimizing Bulk API 2.0 job configurationdata-archival-strategies— use when historical Case data should be archived to external storage rather than migrated into Salesforce
Official Sources Used
- EmailMessage Object Reference — https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_emailmessage.htm
- CaseComment Object Reference — https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_casecomment.htm
- ContentVersion Object Reference — https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_contentversion.htm
- ContentDocumentLink Object Reference — https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_contentdocumentlink.htm
- EmailMessageRelation Object Reference — https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_emailmessagerelation.htm
- CaseHistory Object Reference — https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_casehistory.htm
- Bulk API 2.0 Developer Guide — https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm