Skip to content

Commit e476b47

Browse files
committed
Generate summaries for attachments
1 parent 9be87c0 commit e476b47

10 files changed

Lines changed: 125 additions & 14 deletions

backend/src/entities/file-attachment.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,31 @@ export class FileAttachment extends BaseEntity {
2323
@Property()
2424
size: number;
2525

26+
@Property()
27+
summary: string;
28+
2629
constructor({
2730
emailDraft,
2831
s3Key,
2932
filename,
3033
mimeType,
3134
size,
35+
summary,
3236
}: {
3337
emailDraft: EmailDraft;
3438
s3Key: string;
3539
filename: string;
3640
mimeType: string;
3741
size: number;
42+
summary: string;
3843
}) {
3944
super();
4045
this.emailDraft = emailDraft;
4146
this.s3Key = s3Key;
4247
this.filename = filename;
4348
this.mimeType = mimeType;
4449
this.size = size;
50+
this.summary = summary;
4551
this.validate();
4652
}
4753

@@ -65,5 +71,6 @@ export class FileAttachment extends BaseEntity {
6571
if (!this.mimeType) throw new Error('MIME type is required');
6672
if (this.size === undefined || this.size === null || this.size < 0)
6773
throw new Error('Size must be a non-negative number');
74+
if (!this.summary) throw new Error('Summary is required');
6875
}
6976
}

backend/src/entities/gmail-attachment.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const MIME_TYPE_BY_FILE_EXTENSION: Record<string, string> = {
2323
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
2424
xls: 'application/vnd.ms-excel',
2525
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
26+
ics: 'application/ics',
2627
};
2728

2829
@Entity({ tableName: 'gmail_attachments' })
@@ -45,6 +46,9 @@ export class GmailAttachment extends BaseEntity {
4546
@Property()
4647
contentId?: string;
4748

49+
@Property()
50+
summary?: string;
51+
4852
constructor({
4953
gmailAccount,
5054
emailMessage,
@@ -73,6 +77,11 @@ export class GmailAttachment extends BaseEntity {
7377
this.validate();
7478
}
7579

80+
setSummary(summary: string) {
81+
this.summary = summary;
82+
this.validate();
83+
}
84+
7685
get derivedMimeType() {
7786
if (this.mimeType === 'application/octet-stream') {
7887
const ext = this.filename.toLowerCase().split('.').pop();
@@ -85,6 +94,13 @@ export class GmailAttachment extends BaseEntity {
8594
return this.mimeType;
8695
}
8796

97+
get llmMimeType() {
98+
if (this.mimeType === 'application/ics') {
99+
return 'text/plain'; // LLMs don't support text/calendar, so we treat it as plain text (e.g. for .ics files)
100+
}
101+
return this.derivedMimeType;
102+
}
103+
88104
static toJson(gmailAttachment: Loaded<GmailAttachment>) {
89105
return {
90106
id: gmailAttachment.id,
@@ -107,5 +123,6 @@ export class GmailAttachment extends BaseEntity {
107123
if (!this.mimeType) throw new Error('MIME type is required');
108124
if (this.size === undefined || this.size === null || this.size < 0)
109125
throw new Error('Size must be a non-negative number');
126+
if (this.summary === '') throw new Error('Summary cannot be an empty string');
110127
}
111128
}

backend/src/migrations/.snapshot-bordly_dev.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,16 @@
10191019
"nullable": true,
10201020
"length": 255,
10211021
"mappedType": "string"
1022+
},
1023+
"summary": {
1024+
"name": "summary",
1025+
"type": "varchar(255)",
1026+
"unsigned": false,
1027+
"autoincrement": false,
1028+
"primary": false,
1029+
"nullable": true,
1030+
"length": 255,
1031+
"mappedType": "string"
10221032
}
10231033
},
10241034
"name": "gmail_attachments",
@@ -1796,6 +1806,16 @@
17961806
"primary": false,
17971807
"nullable": false,
17981808
"mappedType": "integer"
1809+
},
1810+
"summary": {
1811+
"name": "summary",
1812+
"type": "varchar(255)",
1813+
"unsigned": false,
1814+
"autoincrement": false,
1815+
"primary": false,
1816+
"nullable": false,
1817+
"length": 255,
1818+
"mappedType": "string"
17991819
}
18001820
},
18011821
"name": "file_attachments",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Migration } from '@mikro-orm/migrations';
2+
3+
export class Migration20260304144014_add_summary_to_attachments extends Migration {
4+
override async up(): Promise<void> {
5+
this.addSql(`alter table "gmail_attachments" add column "summary" varchar(255) null;`);
6+
7+
this.addSql(`alter table "file_attachments" add column "summary" varchar(255) not null;`);
8+
}
9+
10+
override async down(): Promise<void> {
11+
this.addSql(`alter table "gmail_attachments" drop column "summary";`);
12+
13+
this.addSql(`alter table "file_attachments" drop column "summary";`);
14+
}
15+
}

backend/src/services/agent.service.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ export interface Context {
3131
userComment: Loaded<Comment>;
3232
}
3333

34-
const BORDLY_AGENT = {
34+
const AGENT_ATTACHMENT_SUMMARY = {
35+
name: 'Gmail Attachment Summary Agent',
36+
instructions: `Analyze the email attachment and provide a concise summary. The summary must be 255 characters or less.`,
37+
model: ENV.LLM_FAST_MODEL,
38+
};
39+
40+
const AGENT_BORDLY = {
3541
name: 'Bordly',
3642
instructions: `You are an AI email assistant called Bordly that helps manage email communications within a Trello-like board card using the provided tools.
3743
@@ -105,7 +111,7 @@ export class AgentService {
105111
requestContext.set('boardCard', populatedBoardCard);
106112
requestContext.set('userComment', userComment);
107113

108-
const agent = AgentService.createAgent(BORDLY_AGENT);
114+
const agent = AgentService.createAgent(AGENT_BORDLY);
109115
const prompt = userComment.contentText;
110116
const messages: MessageInput[] = [...systemInstructions, { role: 'user', content: prompt }];
111117

@@ -160,4 +166,29 @@ export class AgentService {
160166

161167
return { instructions, boardCard };
162168
}
169+
170+
static async generateAttachmentSummary({
171+
data,
172+
filename,
173+
mimeType,
174+
}: {
175+
data: Buffer;
176+
filename: string;
177+
mimeType: string;
178+
}) {
179+
const agent = AgentService.createAgent(AGENT_ATTACHMENT_SUMMARY);
180+
181+
console.log(`[AGENT] Generating summary for attachment ${filename}...`);
182+
const response = await agent.generate([
183+
{
184+
role: 'user',
185+
content: [
186+
{ type: 'text', text: 'Summarize the following email attachment' },
187+
{ type: 'file', filename, mediaType: mimeType, data },
188+
],
189+
},
190+
]);
191+
192+
return response.text.slice(0, 255);
193+
}
163194
}

backend/src/services/email-draft.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export class EmailDraftService {
204204
});
205205

206206
const messageData = await GmailApi.getMessage(gmail, sentMessage.id!);
207-
const emailMessage = EmailMessageService.parseEmailMessage({ gmailAccount, messageData });
207+
const emailMessage = await EmailMessageService.parseEmailMessage({ gmailAccount, messageData });
208208
if (domain) {
209209
emailMessage.domain = domain;
210210
} else {

backend/src/services/email-message.service.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { BoardMemberService } from '@/services/board-member.service';
1414
import { DomainService } from '@/services/domain.service';
1515
import { EmailDraftService } from '@/services/email-draft.service';
1616
import { GmailAccountService } from '@/services/gmail-account.service';
17+
import { GmailAttachmentService } from '@/services/gmail-attachment.service';
1718
import { htmlToText } from '@/utils/email';
1819
import { ENV } from '@/utils/env';
1920
import { reportError } from '@/utils/error-tracking';
@@ -52,6 +53,7 @@ const AGENT_CATEGORIZATION = {
5253
- {{categories}}
5354
5455
Only output one of the above categories without any explanation.`,
56+
model: ENV.LLM_FAST_MODEL,
5557
};
5658

5759
export class EmailMessageService {
@@ -198,7 +200,10 @@ export class EmailMessageService {
198200
// Collect EmailMessages and Attachments
199201
let lastExternalHistoryId: string | undefined;
200202
// Return type from parseEmailMessage
201-
const emailMessagesDescByThreadId: Record<string, ReturnType<typeof EmailMessageService.parseEmailMessage>[]> = {};
203+
const emailMessagesDescByThreadId: Record<
204+
string,
205+
Awaited<ReturnType<typeof EmailMessageService.parseEmailMessage>>[]
206+
> = {};
202207
const domainNames = new Set<string>();
203208
const processedMessageIds = new Set<string>();
204209

@@ -208,7 +213,7 @@ export class EmailMessageService {
208213
const messageData = await GmailApi.getMessage(gmail, message.id);
209214
if (messageData.labelIds?.includes(LABEL.DRAFT)) continue; // Skip drafts
210215

211-
const emailMessage = EmailMessageService.parseEmailMessage({ gmailAccount, messageData });
216+
const emailMessage = await EmailMessageService.parseEmailMessage({ gmailAccount, messageData });
212217
(emailMessagesDescByThreadId[emailMessage.externalThreadId] ??= []).push(emailMessage);
213218
domainNames.add(emailMessage.loadedDomain.name);
214219
processedMessageIds.add(message.id);
@@ -232,7 +237,7 @@ export class EmailMessageService {
232237
if (messageData.labelIds?.includes(LABEL.DRAFT)) continue; // Skip drafts
233238

234239
console.log(`[GMAIL] Processing ${gmailAccount.email} thread message ${messageData.id}...`);
235-
const emailMessage = EmailMessageService.parseEmailMessage({ gmailAccount, messageData });
240+
const emailMessage = await EmailMessageService.parseEmailMessage({ gmailAccount, messageData });
236241
(emailMessagesDescByThreadId[emailMessage.externalThreadId] ??= []).push(emailMessage);
237242
domainNames.add(emailMessage.loadedDomain.name);
238243
processedMessageIds.add(messageData.id);
@@ -343,7 +348,7 @@ export class EmailMessageService {
343348
return participant;
344349
}
345350

346-
static parseEmailMessage({
351+
static async parseEmailMessage({
347352
gmailAccount,
348353
messageData,
349354
}: {
@@ -416,6 +421,17 @@ export class EmailMessageService {
416421
size: attachmentData.size,
417422
contentId: attachmentData.contentId,
418423
});
424+
425+
if (!labels.includes(LABEL.SPAM) && !labels.includes(LABEL.TRASH)) {
426+
const data = await GmailAttachmentService.getAttachmentDataBuffer(attachment);
427+
const summary = await AgentService.generateAttachmentSummary({
428+
filename: attachment.filename,
429+
mimeType: attachment.llmMimeType,
430+
data,
431+
});
432+
attachment.setSummary(summary);
433+
}
434+
419435
attachments.push(attachment);
420436
}
421437

@@ -523,7 +539,7 @@ export class EmailMessageService {
523539
continue; // Skip drafts
524540
}
525541

526-
const emailMessage = EmailMessageService.parseEmailMessage({ gmailAccount, messageData });
542+
const emailMessage = await EmailMessageService.parseEmailMessage({ gmailAccount, messageData });
527543
const boardCard = boardCardByThreadId[externalThreadId];
528544

529545
if (!boardCard && !EmailMessageService.boardAccountToSyncWhenNoBoardCard({ emailMessage, gmailAccount })) {
@@ -779,7 +795,7 @@ export class EmailMessageService {
779795
instructions: renderTemplate(AGENT_CATEGORIZATION.instructionsTemplate, { categories: categories.join('\n- ') }),
780796
});
781797

782-
let category = CATEGORIES.OTHER;
798+
let category = categories[0]!;
783799
if (
784800
!emailMessages.some(
785801
(emailMessage) => emailMessage.labels.includes(LABEL.SPAM) || emailMessage.labels.includes(LABEL.TRASH),

backend/src/services/file-attachment.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
22
import type { Loaded } from '@mikro-orm/postgresql';
33
import type { EmailDraft } from '@/entities/email-draft';
44
import { FileAttachment } from '@/entities/file-attachment';
5+
import { AgentService } from '@/services/agent.service';
56
import { orm } from '@/utils/orm';
67
import { S3Client } from '@/utils/s3-client';
78

@@ -23,12 +24,15 @@ export class FileAttachmentService {
2324
const s3Key = `${PREFIX_EMAIL_DRAFTS}/${emailDraft.id}/${randomUUID()}-${filename}`;
2425
await S3Client.uploadFile({ key: s3Key, buffer, contentType: mimeType });
2526

27+
const summary = await AgentService.generateAttachmentSummary({ data: buffer, filename, mimeType });
28+
2629
const draftAttachment = new FileAttachment({
2730
emailDraft,
2831
s3Key,
2932
filename,
3033
mimeType,
3134
size: buffer.length,
35+
summary,
3236
});
3337

3438
orm.em.persist(draftAttachment);

backend/src/tools/gmail-attachment-analyze.tool.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const gmailAttachmentAnalyzeTool = createTool({
2929

3030
const { externalThreadId } = boardCard;
3131
if (!externalThreadId) throw new Error('Board card does not have an external thread ID');
32+
3233
const gmailAttachment = await GmailAttachmentService.findByIdAndExternalThreadId(id, {
3334
externalThreadId,
3435
populate: ['emailMessage.gmailAccount'],
@@ -44,7 +45,7 @@ export const gmailAttachmentAnalyzeTool = createTool({
4445
{
4546
type: 'file',
4647
filename: gmailAttachment.filename,
47-
mediaType: gmailAttachment.derivedMimeType,
48+
mediaType: gmailAttachment.llmMimeType,
4849
data: await GmailAttachmentService.getAttachmentDataBuffer(gmailAttachment),
4950
},
5051
],
@@ -54,7 +55,7 @@ export const gmailAttachmentAnalyzeTool = createTool({
5455
const result = {
5556
output: response.text,
5657
filename: gmailAttachment.filename,
57-
mimeType: gmailAttachment.derivedMimeType,
58+
mimeType: gmailAttachment.llmMimeType,
5859
size: gmailAttachment.size,
5960
};
6061

frontend/src/components/board-card/email-message-card.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,8 @@ export const EmailMessageCard = ({
194194
</AvatarFallback>
195195
</Avatar>
196196
<div className="flex flex-col w-full min-w-0">
197-
<div className="flex justify-between items-start gap-2">
198-
<div className="flex flex-col">
197+
<div className="flex justify-between items-start gap-16">
198+
<div className="flex flex-col min-w-0">
199199
{firstParticipantName === firstParticipant.email ? (
200200
<div className="text-sm font-medium">{firstParticipantName}</div>
201201
) : (
@@ -263,7 +263,7 @@ export const EmailMessageCard = ({
263263
</Popover>
264264
</div>
265265
</div>
266-
<div className="flex gap-3">
266+
<div className="flex gap-3 flex-shrink-0">
267267
{blockedTrackerDomains.length > 0 && (
268268
<Popover>
269269
<PopoverTrigger asChild>

0 commit comments

Comments
 (0)