diff --git a/INVESTIGATION_TEMPLATE_HTML_TRANSFORMATION.md b/INVESTIGATION_TEMPLATE_HTML_TRANSFORMATION.md new file mode 100644 index 0000000..eb4bafb --- /dev/null +++ b/INVESTIGATION_TEMPLATE_HTML_TRANSFORMATION.md @@ -0,0 +1,289 @@ +# Investigation: Template HTML Transformation and Content Duplication + +**Related Issues:** +- [resend-python#186](https://github.com/resend/resend-python/issues/186) - Templates.create() transforms raw HTML +- [resend-python#187](https://github.com/resend/resend-python/issues/187) - Templates.update() appends HTML instead of replacing it +- Linear: PRODUCT-1427 - Opening template rewrites HTML and duplicates content + +**Investigation Date:** February 20, 2026 + +## Summary + +The Resend dashboard template editor processes raw HTML through a React Email rendering pipeline and persists the transformed result back to storage **every time a template is opened**, even when no edits are made. This causes: + +1. **HTML Transformation:** Original HTML gets bloated with React Email artifacts (data-id attributes, HTML comments, inline styles) +2. **Content Duplication:** Subsequent updates via API cause content to be duplicated in the editor +3. **Unexpected Behavior:** HTML sent via API differs from what's retrieved after dashboard viewing + +## Root Cause Analysis + +### The Problem Flow + +1. **Create Template via API** (3,267 chars of HTML) + ```python + resend.Templates.create({ + "name": "test-template", + "html": "..." # Raw HTML + }) + ``` + +2. **GET /templates/{id}** → Returns identical HTML (3,267 chars) ✅ + +3. **Open Template in Dashboard** (no edits, just view it) + - Dashboard renders HTML through React Email pipeline + - React Email components add artifacts: + - `data-id="__react-email-column"` attributes + - `` and `` comment markers + - Injected inline styles + - Transformed HTML structure + - **Dashboard saves transformed HTML back to storage** ❌ + +4. **GET /templates/{id}** → HTML now ~21,000+ chars ❌ + +5. **Update via API with original HTML** + ```python + resend.Templates.update({ + "id": template_id, + "html": "..." # Same original HTML + }) + ``` + +6. **Open Dashboard Again** → Content appears duplicated ❌ + +### Source of Artifacts + +Investigation of the [react-email](https://github.com/resend/react-email) repository reveals React Email components hardcode `data-id` attributes: + +**packages/column/src/column.tsx:** +```typescript +export const Column = React.forwardRef( + ({ children, style, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +``` + +**packages/markdown/src/markdown.tsx:** +```typescript +
+ {/* content */} +
+``` + +When raw HTML is rendered through React Email (even just for viewing), these components inject their attributes and transform the HTML structure. + +## Evidence + +### Reproduction Script + +A [Node.js reproduction script](https://gist.github.com/drish/5352959c0dca9abd96141b301ea317a1) demonstrates: + +- API behavior is correct (PATCH replaces HTML exactly as sent) +- Transformation only occurs after opening template in dashboard +- Issue affects both Python SDK and Node.js SDK identically +- **Conclusion: Platform/dashboard issue, not SDK-specific** + +### Example Transformation + +**Original HTML:** +```html + + + + + +
Cell 1Cell 2
+``` + +**After Dashboard View:** +```html + + + + + +
Cell 1Cell 2
+``` + +## Impact + +### User-Reported Problems + +1. **HTML Bloat:** Templates grow 6-7x in size after dashboard viewing +2. **Content Duplication:** Updates cause content to appear multiple times +3. **Unexpected Behavior:** HTML sent via API ≠ HTML retrieved after dashboard interaction +4. **Loss of Control:** Users lose ability to maintain exact HTML structure + +### Affected Users + +- Users creating templates via API with custom HTML +- Users expecting API to preserve exact HTML formatting +- Users with CSS that relies on specific HTML structure +- Users with dark mode or complex styling (as reported in issues) + +## Proposed Solutions + +### Solution 1: Prevent Auto-Save on View (Recommended) + +**Location:** Dashboard template editor code (private repository) + +**Fix:** Only persist HTML changes when user explicitly saves, not on template view/load. + +```typescript +// Current (problematic): +function onTemplateLoad(template) { + const transformed = renderThroughReactEmail(template.html); + saveToStorage(transformed); // ❌ Don't do this + displayInEditor(transformed); +} + +// Proposed: +function onTemplateLoad(template) { + const transformed = renderThroughReactEmail(template.html); + displayInEditor(transformed); // ✅ Only display, don't save +} + +function onUserSave(editorContent) { + saveToStorage(editorContent); // ✅ Only save on explicit user action +} +``` + +**Benefits:** +- Preserves original HTML until user makes intentional changes +- Fixes both transformation and duplication issues +- No SDK changes required + +### Solution 2: Add "plainText" Mode to Dashboard + +**Location:** Dashboard template editor code + +**Fix:** When loading templates created via API (not through React Email editor), display in plain HTML mode without React Email transformation. + +```typescript +function onTemplateLoad(template) { + if (template.createdViaAPI && !template.usesReactEmailComponents) { + displayAsPlainHTML(template.html); // No transformation + } else { + const transformed = renderThroughReactEmail(template.html); + displayInEditor(transformed); + } +} +``` + +**Benefits:** +- Preserves API-created templates +- Allows React Email templates to work as expected +- Clear distinction between template types + +### Solution 3: Add Render Options to React Email + +**Location:** react-email repository + +**Fix:** Add option to skip adding `data-id` attributes during rendering. + +```typescript +export const Column = React.forwardRef( + ({ children, style, includeDataId = true, ...props }, ref) => { + const dataIdProp = includeDataId ? { 'data-id': '__react-email-column' } : {}; + return ( + + {children} + + ); + }, +); +``` + +**Benefits:** +- Allows clean HTML output when needed +- Maintains editor functionality when attributes are included +- Flexible for different use cases + +**Drawbacks:** +- Requires changes across multiple React Email components +- May affect editor features that rely on these attributes + +### Solution 4: Template Versioning + +**Location:** Dashboard backend + +**Fix:** Store both original and transformed versions, serve appropriate version based on request source. + +```typescript +interface Template { + id: string; + name: string; + originalHtml: string; // Preserved as submitted via API + transformedHtml?: string; // For dashboard editor use + // ... +} +``` + +**Benefits:** +- Preserves original HTML for API consumers +- Allows dashboard to work with transformed version +- Non-breaking change + +**Drawbacks:** +- Increased storage requirements +- More complex synchronization logic + +## Recommended Action Plan + +1. **Immediate Fix (Critical):** Implement Solution 1 - Stop auto-saving transformed HTML on template view +2. **Short-term:** Implement Solution 2 - Add plain HTML mode for API-created templates +3. **Long-term:** Consider Solution 4 - Template versioning for robust handling of both API and UI workflows + +## SDK Considerations + +The Python SDK (and all other SDKs) are working correctly. No SDK changes are required. The issue is entirely in the dashboard/backend platform code. + +### Workaround for Users (Until Fix) + +Currently, users should: +1. Create/update templates via API +2. **Avoid opening templates in dashboard** if HTML preservation is critical +3. Use the Resend API directly for all template operations + +## Testing Recommendations + +After implementing fixes, verify: + +1. ✅ Template created via API → HTML unchanged after dashboard view +2. ✅ Template updated via API → No content duplication +3. ✅ Template created in dashboard → React Email features work correctly +4. ✅ Template edited in dashboard → Changes saved correctly +5. ✅ API GET returns exact HTML that was PUT/PATCH + +## Related Code Repositories + +- **resend-python:** SDK confirmed working correctly +- **resend-node:** SDK confirmed working correctly +- **react-email:** Contains components that add data-id attributes +- **resend-openapi:** API specification (does not indicate transformation behavior) +- **Dashboard (private):** Contains problematic auto-save logic + +## Verification + +To verify the fix works: + +```bash +# Run the reproduction script from the gist +node reproduction-script.js + +# Expected results after fix: +# - Original HTML length: 3267 chars +# - After dashboard view: 3267 chars (unchanged) +# - After update: 3267 chars (unchanged) +# - HTML identical to original: true (always) +``` + +## Contributors + +- **Investigation:** João (drish) - identified root cause +- **Reproduction:** Node.js script demonstrating issue +- **Analysis:** Cursor AI Agent - traced issue to React Email component attributes and dashboard auto-save behavior diff --git a/TEMPLATE_ISSUE_README.md b/TEMPLATE_ISSUE_README.md new file mode 100644 index 0000000..638cd05 --- /dev/null +++ b/TEMPLATE_ISSUE_README.md @@ -0,0 +1,71 @@ +# Template HTML Transformation Issue + +## Quick Links + +- **Full Investigation Report:** [INVESTIGATION_TEMPLATE_HTML_TRANSFORMATION.md](./INVESTIGATION_TEMPLATE_HTML_TRANSFORMATION.md) +- **Python Reproduction Script:** [examples/investigation_template_transformation.py](./examples/investigation_template_transformation.py) +- **Related Issues:** [#186](https://github.com/resend/resend-python/issues/186), [#187](https://github.com/resend/resend-python/issues/187) + +## TL;DR + +**Problem:** Opening a template in the Resend dashboard causes the HTML to be transformed through React Email and saved back to storage, even when no edits are made. This results in: +- HTML bloat (6-7x size increase) +- Content duplication on subsequent updates +- Loss of exact HTML formatting + +**Root Cause:** Dashboard auto-saves React Email-transformed HTML (with `data-id` attributes and comments) instead of preserving the original HTML. + +**Status:** Confirmed platform issue, not an SDK bug. The Python SDK is working correctly. + +**Workaround:** Avoid opening templates in the dashboard if HTML preservation is critical. Use API-only operations. + +## Running the Investigation + +To reproduce the issue yourself: + +```bash +export RESEND_API_KEY="re_your_api_key" +python examples/investigation_template_transformation.py +``` + +The script will: +1. Create a template with complex HTML (3,267 chars) +2. Verify HTML is preserved after creation +3. Prompt you to open the template in dashboard +4. Show HTML has been transformed (~21,000+ chars) +5. Demonstrate content duplication on updates + +## Proposed Solutions + +See the [full investigation report](./INVESTIGATION_TEMPLATE_HTML_TRANSFORMATION.md#proposed-solutions) for detailed solutions, including: + +1. **Stop auto-saving transformed HTML** (recommended immediate fix) +2. **Add plain HTML mode** for API-created templates +3. **Make React Email data-id attributes optional** +4. **Template versioning** to store both original and transformed HTML + +## For Resend Team + +The fix needs to be implemented in the dashboard/platform code (likely a private repository). The specific area to investigate: + +```typescript +// Dashboard template editor (pseudo-code) +function onTemplateLoad(template) { + const transformed = renderThroughReactEmail(template.html); + saveToStorage(transformed); // ❌ This line is the problem + displayInEditor(transformed); +} +``` + +Should be: + +```typescript +function onTemplateLoad(template) { + const transformed = renderThroughReactEmail(template.html); + displayInEditor(transformed); // ✅ Display only, don't auto-save +} +``` + +## SDK Impact + +**No SDK changes are required.** All SDKs (Python, Node.js, etc.) are functioning correctly. The issue is entirely in the platform/dashboard layer. diff --git a/examples/investigation_template_transformation.py b/examples/investigation_template_transformation.py new file mode 100644 index 0000000..d5e1ef0 --- /dev/null +++ b/examples/investigation_template_transformation.py @@ -0,0 +1,320 @@ +""" +Investigation Script: Template HTML Transformation Issue + +This script demonstrates the issue where opening a template in the Resend dashboard +causes the HTML to be transformed through React Email pipeline and saved back to storage. + +Related Issues: +- https://github.com/resend/resend-python/issues/186 +- https://github.com/resend/resend-python/issues/187 +- Linear: PRODUCT-1427 + +Usage: + export RESEND_API_KEY="re_..." + python examples/investigation_template_transformation.py +""" + +import os +import time +from typing import Optional + +import resend + +# Same HTML used in the Node.js investigation +COMPLEX_HTML = """ + + + + + + + + + + + + + + +
+ + + + + + + +
+

Monthly Report

+

Borders are blue in light mode and red in dark mode.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricValueChange
Emails Sent12,450+18%
Open Rate42.3%+2.1%
Click Rate8.7%-0.4%
Bounces23-12
+
+
+ +""" + + +def find_first_diff(a: str, b: str) -> Optional[int]: + """Find the first character position where two strings differ.""" + min_len = min(len(a), len(b)) + for i in range(min_len): + if a[i] != b[i]: + return i + return min_len if len(a) != len(b) else None + + +def pause(message: str) -> None: + """Pause execution and wait for user input.""" + print("\n" + "=" * 60) + print(f"PAUSE: {message}") + print("=" * 60) + input("Press Enter to continue...\n") + + +def main(): + """Run the investigation.""" + # Ensure API key is set + resend.api_key = os.environ.get("RESEND_API_KEY") + if not resend.api_key: + print("ERROR: RESEND_API_KEY environment variable not set") + return + + html_length = len(COMPLEX_HTML) + print(f"Original HTML length: {html_length} chars\n") + + # Step 1: Create template + print("--- Step 1: Creating template ---") + timestamp = int(time.time()) + create_params: resend.Templates.CreateParams = { + "name": f"Investigation Test {timestamp}", + "subject": "Welcome", + "html": COMPLEX_HTML, + } + + created = resend.Templates.create(create_params) + template_id = created["id"] + print(f"Created template ID: {template_id}") + + # Step 2: Retrieve via API and compare + print("\n--- Step 2: Retrieving via API ---") + fetched = resend.Templates.get(template_id) + + fetched_length = len(fetched["html"]) + html_match = fetched["html"] == COMPLEX_HTML + print(f"Retrieved HTML length: {fetched_length} chars") + print(f"HTML identical to original: {html_match}") + + if not html_match: + print("\nDIFFERENCES FOUND:") + diff_pos = find_first_diff(COMPLEX_HTML, fetched["html"]) + print(f"First divergence at char: {diff_pos}") + + # Step 3: Pause to check dashboard + pause( + "Open the template in the Resend dashboard editor, then press Enter. " + "The dashboard should show TRANSFORMED HTML (React Email artifacts)." + ) + + # Step 3b: Retrieve AFTER dashboard view to capture the damage + print("--- Step 3b: Retrieving via API AFTER dashboard view ---") + after_dashboard = resend.Templates.get(template_id) + after_dashboard_length = len(after_dashboard["html"]) + after_dashboard_match = after_dashboard["html"] == COMPLEX_HTML + print(f"After-dashboard HTML length: {after_dashboard_length} chars") + print(f"HTML identical to original: {after_dashboard_match}") + + if not after_dashboard_match: + increase = after_dashboard_length / html_length + print( + f"Dashboard MUTATED stored HTML: {html_length} -> {after_dashboard_length} chars " + f"({increase:.1f}x increase)" + ) + + # Check for React Email artifacts + if "data-id" in after_dashboard["html"]: + print(" → Detected 'data-id' attributes (React Email artifact)") + if "" in after_dashboard["html"]: + print(" → Detected '' comments (React Email artifact)") + if "__react-email-column" in after_dashboard["html"]: + print(" → Detected '__react-email-column' (React Email artifact)") + + # Step 4: Update with the same HTML + print("\n--- Step 4: Updating template with same HTML ---") + update_params: resend.Templates.UpdateParams = { + "id": template_id, + "html": COMPLEX_HTML, + } + resend.Templates.update(update_params) + print("Update response completed") + + # Retrieve again to verify + after_update = resend.Templates.get(template_id) + after_update_length = len(after_update["html"]) + after_update_match = after_update["html"] == COMPLEX_HTML + print(f"After-update HTML length: {after_update_length} chars") + print(f"HTML identical to original: {after_update_match}") + + if not after_update_match: + print("\nDIFFERENCES FOUND:") + diff_pos = find_first_diff(COMPLEX_HTML, after_update["html"]) + print(f"First divergence at char: {diff_pos}") + + # Step 5: Pause to check dashboard for duplication + pause( + "Check the Resend dashboard again. The template should now show DUPLICATED content " + "(full body repeated, separated by


)." + ) + + # Step 6: Second update to check compounding + print("--- Step 6: Second update (checking compounding) ---") + resend.Templates.update(update_params) + + after_update2 = resend.Templates.get(template_id) + after_update2_length = len(after_update2["html"]) + after_update2_match = after_update2["html"] == COMPLEX_HTML + print(f"After 2nd update HTML length: {after_update2_length} chars") + print(f"HTML identical to original: {after_update2_match}") + + # Summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + print(f"Original HTML: {html_length} chars") + print(f"After create (API): {fetched_length} chars - Match: {html_match}") + print( + f"After dashboard view: {after_dashboard_length} chars - Match: {after_dashboard_match}" + ) + print( + f"After update (API): {after_update_length} chars - Match: {after_update_match}" + ) + print( + f"After 2nd update: {after_update2_length} chars - Match: {after_update2_match}" + ) + + if not after_dashboard_match: + increase = after_dashboard_length / html_length + print(f"\nKEY FINDING: Opening the dashboard editor mutated stored HTML") + print( + f"from {html_length} to {after_dashboard_length} chars ({increase:.1f}x increase)." + ) + print("The API never transforms HTML — the dashboard's React Email") + print("rendering layer rewrites stored data on every view.") + + if html_match and after_update_match and after_update2_match: + print("\nAPI behavior is correct — PATCH replaces HTML exactly as sent.") + print( + "Both #186 and #187 are caused by the dashboard editor, not the API or SDK." + ) + + # Step 7: Cleanup + print("\n--- Cleanup: Removing template ---") + try: + resend.Templates.remove(template_id) + print("Template removed successfully.") + except Exception as e: + print(f"Remove failed: {e}") + + +if __name__ == "__main__": + main()