Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 27 additions & 30 deletions apps/email/client/components/create/email-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ export function EmailComposer({
const [imageQuality, setImageQuality] = useState<ImageQuality>(
(settings?.settings?.imageCompression as unknown as ImageQuality) || 'medium',
);
const [activeReplyId] = useQueryState('activeReplyId');
const [toggleToolbar, setToggleToolbar] = useState(false);
const processAndSetAttachments = async (
filesToProcess: File[],
Expand Down Expand Up @@ -689,36 +688,34 @@ export function EmailComposer({
</div>

{/* Subject */}
{!activeReplyId ? (
<div className="flex items-center gap-2 border-b p-3">
<p className="text-sm font-medium text-[#8C8C8C]">Subject:</p>
<input
className="h-4 w-full bg-transparent text-sm font-normal leading-normal text-black placeholder:text-[#797979] focus:outline-none dark:text-white/90"
placeholder="Re: Design review feedback"
value={subjectInput}
onChange={(e) => {
const value = replaceEmojiShortcodes(e.target.value);
setValue('subject', value);
setHasUnsavedChanges(true);
}}
/>
<button
onClick={handleGenerateSubject}
disabled={isLoading || isGeneratingSubject || messageLength < 1}
className="hover:bg-gray-50 dark:hover:bg-[#404040] transition-colors cursor-pointer rounded p-1"
>
<div className="flex items-center justify-center gap-2.5 pl-0.5">
<div className="flex h-5 items-center justify-center gap-1 rounded-sm">
{isGeneratingSubject ? (
<Loader className="h-3.5 w-3.5 animate-spin fill-black dark:fill-white" />
) : (
<Sparkles className="h-3.5 w-3.5 fill-black dark:fill-white" />
)}
</div>
<div className="flex items-center gap-2 border-b p-3">
<p className="text-sm font-medium text-[#8C8C8C]">Subject:</p>
<input
className="h-4 w-full bg-transparent text-sm font-normal leading-normal text-black placeholder:text-[#797979] focus:outline-none dark:text-white/90"
placeholder="Re: Design review feedback"
value={subjectInput}
onChange={(e) => {
const value = replaceEmojiShortcodes(e.target.value);
setValue('subject', value);
setHasUnsavedChanges(true);
}}
/>
<button
onClick={handleGenerateSubject}
disabled={isLoading || isGeneratingSubject || messageLength < 1}
className="hover:bg-gray-50 dark:hover:bg-[#404040] transition-colors cursor-pointer rounded p-1"
>
<div className="flex items-center justify-center gap-2.5 pl-0.5">
<div className="flex h-5 items-center justify-center gap-1 rounded-sm">
{isGeneratingSubject ? (
<Loader className="h-3.5 w-3.5 animate-spin fill-black dark:fill-white" />
) : (
<Sparkles className="h-3.5 w-3.5 fill-black dark:fill-white" />
)}
</div>
</button>
</div>
) : null}
</div>
</button>
</div>

{/* From */}
{aliases && aliases.length > 1 ? (
Expand Down
71 changes: 14 additions & 57 deletions apps/email/client/components/mail/reply-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useUndoSend } from '@/hooks/use-undo-send';
import { constructReplyBody, constructForwardBody } from '@/lib/utils';
import { useActiveConnection } from '@/hooks/use-connections';
import { useEmailAliases } from '@/hooks/use-email-aliases';
import { getReplyDefaults, type ReplyMode } from '@/lib/reply-defaults';
import { EmailComposer } from '../create/email-composer';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { useTRPC } from '@/providers/query-provider';
Expand All @@ -14,7 +15,7 @@ import { useDraft } from '@/hooks/use-drafts';
import { m } from '@/paraglide/messages';
import type { Sender } from '@/types';
import { useQueryState } from 'nuqs';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import posthog from 'posthog-js';
import { toast } from 'sonner';

Expand Down Expand Up @@ -43,60 +44,16 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) {
const replyToMessage =
(messageId && emailData?.messages.find((msg) => msg.id === messageId)) || emailData?.latest;

// Initialize recipients and subject when mode changes
useEffect(() => {
if (!replyToMessage || !mode || !activeConnection?.email) return;

const userEmail = activeConnection.email.toLowerCase();
const senderEmail = replyToMessage.sender.email.toLowerCase();

// Set subject based on mode

if (mode === 'reply') {
// Reply to sender
const to: string[] = [];

// If the sender is not the current user, add them to the recipients
if (senderEmail !== userEmail) {
to.push(replyToMessage.sender.email);
} else if (replyToMessage.to && replyToMessage.to.length > 0 && replyToMessage.to[0]?.email) {
// If we're replying to our own email, reply to the first recipient
to.push(replyToMessage.to[0].email);
}

// Initialize email composer with these recipients
// Note: The actual initialization happens in the EmailComposer component
} else if (mode === 'replyAll') {
const to: string[] = [];
const cc: string[] = [];

// Add original sender if not current user
if (senderEmail !== userEmail) {
to.push(replyToMessage.sender.email);
}

// Add original recipients from To field
replyToMessage.to?.forEach((recipient) => {
const recipientEmail = recipient.email.toLowerCase();
if (recipientEmail !== userEmail && recipientEmail !== senderEmail) {
to.push(recipient.email);
}
});

// Add CC recipients
replyToMessage.cc?.forEach((recipient) => {
const recipientEmail = recipient.email.toLowerCase();
if (recipientEmail !== userEmail && !to.includes(recipient.email)) {
cc.push(recipient.email);
}
});

// Initialize email composer with these recipients
} else if (mode === 'forward') {
// For forward, we start with empty recipients
// Just set the subject and include the original message
const replyDefaults = useMemo(() => {
if (!replyToMessage || !mode || !activeConnection?.email) {
return { to: [], cc: [], subject: '' };
}
}, [mode, replyToMessage, activeConnection?.email]);

return getReplyDefaults(replyToMessage, mode as ReplyMode, [
activeConnection.email,
...(aliases ?? []).map((alias) => alias.email),
]);
}, [activeConnection?.email, aliases, mode, replyToMessage]);

const handleSendEmail = async (data: {
to: string[];
Expand Down Expand Up @@ -270,10 +227,10 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) {
setActiveReplyId(null);
}}
initialMessage={draft?.content ?? latestDraft?.decodedBody}
initialTo={ensureEmailArray(draft?.to)}
initialCc={ensureEmailArray(draft?.cc)}
initialTo={draft?.to ? ensureEmailArray(draft.to) : replyDefaults.to}
initialCc={draft?.cc ? ensureEmailArray(draft.cc) : replyDefaults.cc}
initialBcc={ensureEmailArray(draft?.bcc)}
initialSubject={draft?.subject}
initialSubject={draft?.subject || replyDefaults.subject}
autofocus={true}
settingsLoading={settingsLoading}
replyingTo={replyToMessage?.sender.email}
Expand Down
46 changes: 46 additions & 0 deletions apps/email/client/lib/reply-defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
type Address = { email: string };

type ReplyMessage = {
sender: Address;
to?: Address[];
cc?: Address[];
subject?: string | null;
};

export type ReplyMode = "reply" | "replyAll" | "forward";

export function getReplyDefaults(message: ReplyMessage, mode: ReplyMode, ownEmails: string[]) {
const ownAddresses = new Set(ownEmails.map((email) => email.toLowerCase()));
const uniqueExternal = (addresses: string[]) =>
addresses.filter(
(email, index, all) =>
!ownAddresses.has(email.toLowerCase()) &&
all.findIndex((candidate) => candidate.toLowerCase() === email.toLowerCase()) === index,
);
const originalSubject = message.subject?.trim() || "";
const prefix = mode === "forward" ? "Fwd:" : "Re:";
const subject = new RegExp(`^${prefix}`, "i").test(originalSubject)
? originalSubject
: `${prefix} ${originalSubject}`.trim();

if (mode === "forward") return { to: [], cc: [], subject };

const sender = message.sender.email;
const fallbackRecipient = message.to?.find(
(recipient) => !ownAddresses.has(recipient.email.toLowerCase()),
)?.email;
const to = uniqueExternal(
[
ownAddresses.has(sender.toLowerCase()) ? (fallbackRecipient ?? "") : sender,
...(mode === "replyAll" ? (message.to ?? []).map((recipient) => recipient.email) : []),
].filter(Boolean),
);
const cc =
mode === "replyAll"
? uniqueExternal((message.cc ?? []).map((recipient) => recipient.email)).filter(
(email) => !to.some((toEmail) => toEmail.toLowerCase() === email.toLowerCase()),
)
: [];

return { to, cc, subject };
}
42 changes: 42 additions & 0 deletions apps/email/server/test/reply-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "bun:test";
import { getReplyDefaults } from "../../client/lib/reply-defaults";

const message = {
sender: { email: "sender@example.com" },
to: [{ email: "me@example.com" }, { email: "teammate@example.com" }],
cc: [{ email: "copy@example.com" }, { email: "ME@example.com" }],
subject: "Project update",
};

describe("getReplyDefaults", () => {
it("addresses a reply to the sender and preserves a reply subject", () => {
expect(getReplyDefaults(message, "reply", ["me@example.com"])).toEqual({
to: ["sender@example.com"],
cc: [],
subject: "Re: Project update",
});
});

it("addresses reply-all without the current user or duplicate recipients", () => {
expect(getReplyDefaults(message, "replyAll", ["me@example.com"])).toEqual({
to: ["sender@example.com", "teammate@example.com"],
cc: ["copy@example.com"],
subject: "Re: Project update",
});
});

it("replies to an external recipient when the original sender is the current user", () => {
expect(
getReplyDefaults({ ...message, sender: { email: "me@example.com" } }, "reply", [
"me@example.com",
]),
).toMatchObject({ to: ["teammate@example.com"] });
});

it("does not duplicate an existing subject prefix", () => {
expect(
getReplyDefaults({ ...message, subject: "Re: Project update" }, "reply", ["me@example.com"])
.subject,
).toBe("Re: Project update");
});
});