---
name: case-history-migration
description: "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 generic multi-object data migration planning (use data-migration-planning). NOT for ongoing email sync or Einstein Activity Capture configuration. NOT for Contact or Account record migration."
category: data
salesforce-version: "Spring '25+"
well-architected-pillars:
  - Reliability
  - Operational Excellence
  - Performance
triggers:
  - "how do I migrate case email history from our legacy help desk into Salesforce"
  - "loading EmailMessage records fails or locks read-only after setting status to sent"
  - "how do I migrate case comments and attachments into Salesforce without losing the case timeline"
  - "ContentDocumentLink insert fails or times out when loading case file attachments in bulk"
  - "how do I preserve historical case emails and attachments when cutting over from Zendesk or ServiceNow to Salesforce"
  - "CaseHistory is not insertable — how do I recreate case audit history in Salesforce"
tags:
  - case-migration
  - email-message
  - case-comment
  - content-version
  - content-document-link
  - case-history
  - service-cloud
  - bulk-api
  - data-migration
  - email-message-relation
inputs:
  - "Source case data export: fields for CaseNumber, Subject, Description, Status, Priority, AccountId, ContactId, OwnerId, CreatedDate, ClosedDate"
  - "Case comment export: CommentBody, ParentId (case), CreatedById, CreatedDate, IsPublished flag"
  - "Email message export: Subject, TextBody, HtmlBody, FromAddress, ToAddress, MessageDate, Headers, Direction (inbound/outbound)"
  - "Attachment/file export: file name, MIME type, binary content, parent case reference, parent email reference"
  - "Source user directory for mapping legacy user IDs to Salesforce UserIds"
  - "Org configuration: Feed Tracking settings, Email-to-Case enabled flag, record type assignments"
outputs:
  - "Load sequence specification: Case → CaseComment → EmailMessage → EmailMessageRelation → ContentVersion → ContentDocumentLink"
  - "CSV column mapping for each object aligned to Bulk API 2.0 upsert format"
  - "EmailMessage Status field guidance: use Status='0' (New) during initial load to avoid permanent read-only lock"
  - "ContentDocumentLink serial load strategy (no Bulk API batching)"
  - "CaseHistory non-insertable workaround via Task records or Feed items"
  - "Post-migration validation queries for case comment counts, email counts, and attachment linkage"
  - "Completed case-history-migration-template.md"
dependencies: []
version: 1.0.0
author: Pranav Nagrecha
updated: 2026-04-06
---

# 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 `CaseHistory` object 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 return `INVALID_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 `Status` to `'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 with `Status='0'` (New) or `Status='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`, and `BccAddress`, but those strings are not automatically linked to Contact, Lead, or User records. `EmailMessageRelation` is 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:

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 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 and `Body` = 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:**
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

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

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:

- [ ] All Cases loaded and row counts verified before any child object loads begin
- [ ] EmailMessage `Status` field value confirmed — no `Status='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:

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

| 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 family
- `bulk-api-and-large-data-loads` — use when load volume exceeds standard limits or when optimizing Bulk API 2.0 job configuration
- `data-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
