Skip to content

Commit 7e54781

Browse files
committed
DRY draft reply generation between AI agent & frontend
1 parent e476b47 commit 7e54781

4 files changed

Lines changed: 104 additions & 70 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ There are two projects in this repository: @frontend and @backend.
22

33
- Use `pnpm` instead of `npm`
44
- Run `make check` after making changes to format, lint, and find type errors
5-
- Do not use `any` type in TypeScript
65
- Do not use `git` unless explicitly asked
76
- When asked to write tests from examples, anonymize test data
87

8+
## TypeScript
9+
10+
- Do not use `any` type in TypeScript
11+
- Avoid explicitly specifying function return types
12+
913
## @frontend
1014

1115
- Prefer React components defined as const arrow functions

backend/src/tools/email-draft-upsert.tool.ts

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import type { RequestContext } from '@mastra/core/request-context';
22
import { createTool } from '@mastra/core/tools';
33
import type { Loaded } from '@mikro-orm/postgresql';
44
import { z } from 'zod';
5-
import type { BoardCard } from '@/entities/board-card';
65
import type { User } from '@/entities/user';
76
import type { Context } from '@/services/agent.service';
87
import { BoardCardService } from '@/services/board-card.service';
98
import { BoardMemberService } from '@/services/board-member.service';
109
import { EmailDraftService } from '@/services/email-draft.service';
1110
import { EmailMessageService } from '@/services/email-message.service';
12-
import { createQuotedHtml } from '@/utils/shared';
11+
import { SenderEmailAddressService } from '@/services/sender-email-address.service';
12+
import { createQuotedHtml, participantToString, replyEmailFields } from '@/utils/shared';
1313
import { shortDateTimeWithWeekday } from '@/utils/time';
1414

1515
export const emailDraftUpsertTool = createTool({
@@ -36,22 +36,49 @@ export const emailDraftUpsertTool = createTool({
3636
await BoardMemberService.populate(userBoardMember, ['user.boardMembers']);
3737
const user = userBoardMember.loadedUser as Loaded<User, 'boardMembers'>;
3838

39-
const boardCard = await BoardCardService.populate(initialBoardCard, ['boardCardReadPositions']);
39+
const boardCard = await BoardCardService.populate(initialBoardCard, ['boardCardReadPositions', 'boardAccount']);
4040

41-
const quotedEmailHtml = await quotedHtml({
42-
boardCard,
43-
userTimeZone,
44-
replyToEmailMessageId: data.replyToEmailMessageId,
45-
});
41+
const replyToEmailMessage = data.replyToEmailMessageId
42+
? await EmailMessageService.findById(boardCard, { id: data.replyToEmailMessageId })
43+
: boardCard.externalThreadId
44+
? await EmailMessageService.findLastByExternalThreadId(boardCard.externalThreadId)
45+
: undefined;
46+
47+
let { subject, from, to, cc, bcc } = data;
48+
49+
let quotedEmailHtml = '';
50+
if (replyToEmailMessage) {
51+
quotedEmailHtml = createQuotedHtml({
52+
from: replyToEmailMessage.from,
53+
sentAt: shortDateTimeWithWeekday(replyToEmailMessage.externalCreatedAt, { timeZone: userTimeZone }),
54+
html: replyToEmailMessage.bodyHtml || '',
55+
text: replyToEmailMessage.bodyText || '',
56+
});
57+
58+
const senderEmailAddresses = await SenderEmailAddressService.findAddressesByBoardAccountAndUser(
59+
boardCard.loadedBoardAccount.board,
60+
{ user, boardAccountId: boardCard.boardAccount.id },
61+
);
62+
63+
const emailFields = replyEmailFields({
64+
replyToMessage: replyToEmailMessage,
65+
senderEmailAddresses: [...senderEmailAddresses],
66+
});
67+
68+
subject = emailFields.subject;
69+
from = participantToString(emailFields.from);
70+
to = emailFields.to?.map(participantToString);
71+
cc = emailFields.cc?.map(participantToString);
72+
}
4673

4774
await EmailDraftService.upsert(boardCard, {
4875
user,
4976
generated: false,
50-
subject: data.subject,
51-
from: data.from,
52-
to: data.to,
53-
cc: data.cc,
54-
bcc: data.bcc,
77+
subject,
78+
from,
79+
to,
80+
cc,
81+
bcc,
5582
bodyHtml: `${data.mainHtml}${quotedEmailHtml}`,
5683
lastEditedByUser: bordlyBoardMember.user,
5784
});
@@ -60,24 +87,3 @@ export const emailDraftUpsertTool = createTool({
6087
return { success: true };
6188
},
6289
});
63-
64-
const quotedHtml = async ({
65-
boardCard,
66-
userTimeZone,
67-
replyToEmailMessageId,
68-
}: {
69-
boardCard: Loaded<BoardCard>;
70-
userTimeZone?: string;
71-
replyToEmailMessageId?: string;
72-
}) => {
73-
if (replyToEmailMessageId) {
74-
const replyToEmailMessage = await EmailMessageService.findById(boardCard, { id: replyToEmailMessageId });
75-
return createQuotedHtml({
76-
from: replyToEmailMessage.from,
77-
sentAt: shortDateTimeWithWeekday(replyToEmailMessage.externalCreatedAt, { timeZone: userTimeZone }),
78-
html: replyToEmailMessage.bodyHtml || '',
79-
text: replyToEmailMessage.bodyText || '',
80-
});
81-
}
82-
return '';
83-
};

backend/src/utils/shared.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,52 @@ export const participantToString = (p: Participant) => (p.name ? `${p.name} <${p
4141

4242
export const isCommentForBordly = (text: string) => text.trim().toLowerCase().startsWith('@bordly');
4343

44+
export const replyEmailFields = ({
45+
replyToMessage,
46+
senderEmailAddresses,
47+
}: {
48+
replyToMessage: {
49+
subject: string;
50+
from: Participant;
51+
to?: Participant[];
52+
cc?: Participant[];
53+
bcc?: Participant[];
54+
replyTo?: Participant;
55+
};
56+
senderEmailAddresses: {
57+
email: string;
58+
name?: string | null;
59+
isDefault: boolean;
60+
}[];
61+
}) => {
62+
if (senderEmailAddresses.length === 0) throw new Error('No sender email addresses available');
63+
64+
const emailParticipantEmails = new Set<string>([
65+
replyToMessage.from.email,
66+
...(replyToMessage.to?.map((p) => p.email) ?? []),
67+
...(replyToMessage.cc?.map((p) => p.email) ?? []),
68+
...(replyToMessage.bcc?.map((p) => p.email) ?? []),
69+
]);
70+
const fromEmailAddress =
71+
senderEmailAddresses.find((addr) => emailParticipantEmails.has(addr.email)) ||
72+
senderEmailAddresses.find((addr) => addr.isDefault) ||
73+
senderEmailAddresses[0]!;
74+
75+
const subject = replyToMessage.subject.toLowerCase().startsWith('re:')
76+
? replyToMessage.subject
77+
: `Re: ${replyToMessage.subject}`;
78+
const from = { email: fromEmailAddress.email, name: fromEmailAddress.name || null } as Participant;
79+
80+
const sent = replyToMessage.from.email === from.email;
81+
const to = sent ? replyToMessage.to : replyToMessage.replyTo ? [replyToMessage.replyTo] : [replyToMessage.from];
82+
const cc = [
83+
...(sent ? [] : (replyToMessage.to?.filter((p) => p.email !== from.email) ?? [])),
84+
...(replyToMessage.cc?.filter((p) => p.email !== from.email) ?? []),
85+
];
86+
87+
return { subject, from, to: to && to.length > 0 ? to : undefined, cc: cc.length > 0 ? cc : undefined };
88+
};
89+
4490
export const createQuotedHtml = ({
4591
from,
4692
sentAt,

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

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useMutation, useQuery } from '@tanstack/react-query';
22
import { useNavigate } from '@tanstack/react-router';
33
import { useEditor } from '@tiptap/react';
4-
import { createQuotedHtml, type Participant, participantToString } from 'bordly-backend/utils/shared';
4+
import { createQuotedHtml, type Participant, participantToString, replyEmailFields } from 'bordly-backend/utils/shared';
55
import { ALargeSmall, Paperclip, Send, Trash } from 'lucide-react';
66
import { useEffect, useMemo, useRef, useState } from 'react';
77
import { useDropzone } from 'react-dropzone';
@@ -72,13 +72,12 @@ export const EmailDraftCard = ({
7272
}) => {
7373
const navigate = useNavigate();
7474
const { queryClient, trpc } = useRouteContext();
75+
const [subject, setSubject] = useState(emailDraft?.subject || boardCard.subject);
7576
const [from, setFrom] = useState(emailDraft ? participantToString(emailDraft.from) : '');
7677
const [to, setTo] = useState(emailDraft ? participantsToString(emailDraft.to) : '');
7778
const [cc, setCc] = useState(emailDraft ? participantsToString(emailDraft.cc) : '');
7879
const [bcc, setBcc] = useState(emailDraft ? participantsToString(emailDraft.bcc) : '');
7980

80-
const noMessages = boardCard.emailMessageCount === 0;
81-
8281
// Set default "From" address using loaded email addresses
8382
const { data: emailAddressesData } = useQuery({
8483
...trpc.senderEmailAddress.getAddressesForBoardMember.queryOptions({
@@ -98,36 +97,15 @@ export const EmailDraftCard = ({
9897
return prevFrom;
9998
});
10099
} else {
101-
// Set from
102-
const emailParticipantEmails = new Set<string>([
103-
...(replyToEmailMessage ? [replyToEmailMessage.from.email] : []),
104-
...(replyToEmailMessage?.to?.map((p) => p.email) ?? []),
105-
...(replyToEmailMessage?.cc?.map((p) => p.email) ?? []),
106-
...(replyToEmailMessage?.bcc?.map((p) => p.email) ?? []),
107-
]);
108-
const fromEmailAddress =
109-
fromEmailAddresses.find((addr) => emailParticipantEmails.has(addr.email)) ||
110-
fromEmailAddresses.find((addr) => addr.isDefault) ||
111-
fromEmailAddresses[0];
112-
setFrom(participantToString(fromEmailAddress as Participant));
113-
114100
if (replyToEmailMessage) {
115-
const sent = replyToEmailMessage!.from.email === fromEmailAddress.email;
116-
// Set to
117-
setTo(
118-
sent
119-
? participantsToString(replyToEmailMessage.to)
120-
: replyToEmailMessage.replyTo
121-
? participantToString(replyToEmailMessage.replyTo)
122-
: participantToString(replyToEmailMessage.from),
123-
);
124-
// Set cc
125-
setCc(
126-
participantsToString([
127-
...(sent ? [] : (replyToEmailMessage.to?.filter((p) => p.email !== fromEmailAddress.email) ?? [])),
128-
...(replyToEmailMessage.cc?.filter((p) => p.email !== fromEmailAddress.email) ?? []),
129-
]),
130-
);
101+
const replyFields = replyEmailFields({
102+
replyToMessage: replyToEmailMessage,
103+
senderEmailAddresses: fromEmailAddresses,
104+
});
105+
setSubject(replyFields.subject);
106+
setFrom(participantToString(replyFields.from));
107+
setTo(participantsToString(replyFields.to));
108+
setCc(participantsToString(replyFields.cc));
131109
}
132110
}
133111
}, [emailDraft, replyToEmailMessage, fromEmailAddresses]);
@@ -158,7 +136,7 @@ export const EmailDraftCard = ({
158136
onExecute: (params) => {
159137
removeEmailDraftData({ trpc, queryClient, params });
160138
removeBoardCardEmailDraftData({ trpc, queryClient, params });
161-
if (noMessages) {
139+
if (boardCard.emailMessageCount === 0) {
162140
removeBoardCardData({ trpc, queryClient, params });
163141
navigate({ to: ROUTES.BOARD.replace('$boardId', boardId) });
164142
}
@@ -245,7 +223,7 @@ export const EmailDraftCard = ({
245223
emailDraftUpsertMutation.mutate({
246224
boardId,
247225
boardCardId,
248-
subject: noMessages ? boardCard.subject : `Re: ${boardCard.subject}`,
226+
subject,
249227
bodyHtml: `${editor.getHTML()}${quotedHtml}`,
250228
from: from.trim(),
251229
to: to ? parseParticipantsInput(to) : undefined,
@@ -274,7 +252,7 @@ export const EmailDraftCard = ({
274252
emailDraftSendMutation.mutate({
275253
boardId,
276254
boardCardId,
277-
subject: noMessages ? boardCard.subject : `Re: ${boardCard.subject}`,
255+
subject,
278256
bodyHtml: `${editor.getHTML()}${quotedHtml}`,
279257
from: from.trim(),
280258
to: to ? parseParticipantsInput(to) : undefined,

0 commit comments

Comments
 (0)