Skip to content

Commit d9d3853

Browse files
authored
✨(images) implement cid: image support (#378)
Parses the email and replaces cid: images with blob urls
1 parent 66b6561 commit d9d3853

File tree

7 files changed

+56
-9
lines changed

7 files changed

+56
-9
lines changed

src/backend/core/api/openapi.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4983,10 +4983,17 @@
49834983
"readOnly": true,
49844984
"title": "Created on",
49854985
"description": "date and time at which a record was created"
4986+
},
4987+
"cid": {
4988+
"type": "string",
4989+
"readOnly": true,
4990+
"nullable": true,
4991+
"description": "Content-ID for inline images"
49864992
}
49874993
},
49884994
"required": [
49894995
"blobId",
4996+
"cid",
49904997
"created_at",
49914998
"id",
49924999
"name",

src/backend/core/api/serializers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ class AttachmentSerializer(serializers.ModelSerializer):
377377
blobId = serializers.UUIDField(source="blob.id", read_only=True)
378378
type = serializers.CharField(source="content_type", read_only=True)
379379
sha256 = serializers.SerializerMethodField()
380+
cid = serializers.CharField(
381+
read_only=True, allow_null=True, help_text="Content-ID for inline images"
382+
)
380383

381384
def get_sha256(self, obj):
382385
"""Convert binary SHA256 to hex string."""
@@ -392,6 +395,7 @@ class Meta:
392395
"type",
393396
"sha256",
394397
"created_at",
398+
"cid",
395399
]
396400
read_only_fields = fields
397401

src/frontend/src/features/api/gen/models/attachment.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,9 @@ export interface Attachment {
2121
readonly sha256: string;
2222
/** date and time at which a record was created */
2323
readonly created_at: string;
24+
/**
25+
* Content-ID for inline images
26+
* @nullable
27+
*/
28+
readonly cid: string | null;
2429
}

src/frontend/src/features/api/utils.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,18 @@ export const isJson = (str: string) => {
2727
return true;
2828
};
2929

30+
export function getApiOrigin() {
31+
return process.env.NEXT_PUBLIC_API_ORIGIN ||
32+
(typeof window !== "undefined" ? window.location.origin : "");
33+
}
34+
3035
/**
3136
* Build the request url from the context url and the base url
3237
*
3338
*/
3439
export function getRequestUrl(pathname: string, params?: Record<string, string>): string {
35-
const origin =
36-
process.env.NEXT_PUBLIC_API_ORIGIN ||
37-
(typeof window !== "undefined" ? window.location.origin : "");
3840

39-
const requestUrl = new URL(`${origin}${pathname}`);
41+
const requestUrl = new URL(`${getApiOrigin()}${pathname}`);
4042

4143
if (params) {
4244
Object.entries(params).forEach(([key, value]) => {

src/frontend/src/features/forms/components/message-form/drive-attachment-picker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature";
88
import { DriveIcon } from "./drive-icon";
99
import { Attachment } from "@/features/api/gen/models/attachment";
1010

11-
export type DriveFile = { url: string } & Omit<Attachment, 'sha256' | 'blobId'>;
11+
export type DriveFile = { url: string } & Omit<Attachment, 'sha256' | 'blobId' | 'cid'>;
1212

1313
type DriveAttachmentPickerProps = {
1414
onPick: (attachments: DriveFile[]) => void;

src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export const ThreadMessage = forwardRef<HTMLElement, ThreadMessageProps>(
275275
<MessageBody
276276
rawTextBody={textBody}
277277
rawHtmlBody={htmlBody}
278+
attachments={message.attachments}
278279
/>
279280
<footer className="thread-message__footer">
280281
{!message.is_draft && (message.attachments.length > 0 || driveAttachments.length > 0) && (

src/frontend/src/features/layouts/components/thread-view/components/thread-message/message-body.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { useCallback, useEffect, useMemo, useRef } from "react";
22
import DomPurify from "dompurify";
33
import { useTranslation } from "react-i18next";
4+
import { Attachment } from "@/features/api/gen/models";
5+
import { getRequestUrl, getApiOrigin } from "@/features/api/utils";
6+
import { getBlobDownloadRetrieveUrl } from "@/features/api/gen/blob/blob";
47

58
type MessageBodyProps = {
69
rawHtmlBody: string;
710
rawTextBody: string;
11+
attachments?: readonly Attachment[];
812
}
913

1014
const CSP = [
11-
// Allow images from our domain and data URIs
12-
"img-src 'self' data: http://localhost:8900",
15+
// Allow images from our domain, data URIs, and API endpoints
16+
`img-src 'self' data: ${getApiOrigin()}`,
1317
// Disable everything else by default
1418
"default-src 'none'",
1519
// No scripts at all
@@ -35,10 +39,22 @@ const CSP = [
3539
"frame-ancestors 'none'",
3640
].join('; ');
3741

38-
const MessageBody = ({ rawHtmlBody, rawTextBody }: MessageBodyProps) => {
42+
const MessageBody = ({ rawHtmlBody, rawTextBody, attachments = [] }: MessageBodyProps) => {
3943
const iframeRef = useRef<HTMLIFrameElement>(null);
4044
const { t } = useTranslation();
4145

46+
// Create a mapping of CID to blob URL for CID image transformation
47+
const cidToBlobUrlMap = useMemo(() => {
48+
const map = new Map<string, string>();
49+
attachments.forEach(attachment => {
50+
if (attachment.cid) {
51+
const blobUrl = getRequestUrl(getBlobDownloadRetrieveUrl(attachment.blobId));
52+
map.set(attachment.cid, blobUrl);
53+
}
54+
});
55+
return map;
56+
}, [attachments]);
57+
4258
DomPurify.addHook(
4359
'afterSanitizeAttributes',
4460
function (node) {
@@ -50,6 +66,18 @@ const MessageBody = ({ rawHtmlBody, rawTextBody }: MessageBodyProps) => {
5066
}
5167
node.setAttribute('rel', 'noopener noreferrer');
5268
}
69+
70+
// Transform CID references in img src attributes
71+
if (node.tagName === 'IMG' && cidToBlobUrlMap.size > 0) {
72+
const src = node.getAttribute('src');
73+
if (src && src.startsWith('cid:')) {
74+
const cid = src.substring(4); // Remove 'cid:' prefix
75+
const blobUrl = cidToBlobUrlMap.get(cid);
76+
if (blobUrl) {
77+
node.setAttribute('src', blobUrl);
78+
}
79+
}
80+
}
5381
}
5482
);
5583

@@ -58,7 +86,7 @@ const MessageBody = ({ rawHtmlBody, rawTextBody }: MessageBodyProps) => {
5886
FORBID_TAGS: ['script', 'object', 'iframe', 'embed', 'audio', 'video'],
5987
ADD_ATTR: ['target', 'rel'],
6088
});
61-
}, [rawHtmlBody, rawTextBody]);
89+
}, [rawHtmlBody, rawTextBody, cidToBlobUrlMap]);
6290

6391
const wrappedHtml = useMemo(() => {
6492
return `

0 commit comments

Comments
 (0)