Enhance email sending functionality by adding support for CC and BCC fields, integrating the 'mimetext' library for MIME message creation, and refactoring the reply composer. Update user settings to remove signature options and improve email parsing logic.

This commit is contained in:
Aj Wazzan
2025-04-09 00:09:32 -07:00
parent b38dcc6f2b
commit 6cbd6f7b90
13 changed files with 254 additions and 379 deletions

View File

@@ -6,22 +6,24 @@ import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { eq } from "drizzle-orm";
import { db } from "@zero/db";
import { getUserSettings } from "./settings";
import { Sender } from "@/types";
export async function sendEmail({
to,
subject,
message,
attachments,
bcc,
cc,
headers: additionalHeaders = {},
includeSignature = true,
}: {
to: string;
to: Sender[];
subject: string;
message: string;
attachments: File[];
headers?: Record<string, string>;
includeSignature?: boolean;
headers?: Record<string, string>;
cc?: Sender[];
bcc?: Sender[];
}) {
if (!to || !subject || !message) {
throw new Error("Missing required fields");
@@ -42,134 +44,22 @@ export async function sendEmail({
throw new Error("Unauthorized, reconnect");
}
// Get user settings to check for signature
let finalMessage = message.trim();
// Create the email HTML structure with optional signature
const htmlTemplate = (content: string, signature?: string) => `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body style="margin: 0; padding: 0;">
${content}
${signature ? `
<div style="margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px; clear: both;">
${signature}
</div>` : ''}
</body>
</html>`;
if (includeSignature) {
const userSettings = await getUserSettings();
if (userSettings?.signature?.enabled && userSettings.signature.content) {
const signatureContent = userSettings.signature.content.trim();
finalMessage = htmlTemplate(finalMessage, signatureContent);
} else {
finalMessage = htmlTemplate(finalMessage);
}
} else {
finalMessage = htmlTemplate(finalMessage);
}
const driver = await createDriver(_connection.providerId, {
auth: {
access_token: _connection.accessToken,
refresh_token: _connection.refreshToken,
email: _connection.email
},
});
const fromName = _connection.name || session.user.name || "Unknown";
const fromEmail = _connection.email || session.user.email;
const fromHeader = fromName ? `${fromName} <${fromEmail}>` : fromEmail;
const domain = fromEmail.split("@")[1];
const randomPart = Math.random().toString(36).substring(2);
const timestamp = Date.now();
const messageId = `<${timestamp}.${randomPart}.zerodotemail@${domain}>`;
const date = new Date().toUTCString();
const boundary = `----=_NextPart_${Date.now().toString(36)}_${Math.random().toString(36).substr(2, 9)}`;
// Start building email content
const emailParts = [
`Content-Type: multipart/mixed; boundary="${boundary}"`,
"MIME-Version: 1.0",
`Date: ${date}`,
`Message-ID: ${messageId}`,
`From: ${fromHeader}`,
`To: ${to
.split(",")
.map((ref) => (ref.startsWith("<") ? ref : `<${ref}>`))
.join(", ")}`,
`Subject: ${subject}`,
`X-Mailer: 0.email`,
`X-Priority: 3`,
`X-MSMail-Priority: Normal`,
// Add threading headers if present
...(additionalHeaders["In-Reply-To"]
? [
`In-Reply-To: ${additionalHeaders["In-Reply-To"]
.split(" ")
.filter(Boolean)
.map((ref) => (ref.startsWith("<") ? ref : `<${ref}>`))
.join(" ")}`,
]
: []),
...(additionalHeaders["References"]
? [
`References: ${additionalHeaders["References"]
.split(" ")
.filter(Boolean)
.map((ref) => (ref.startsWith("<") ? ref : `<${ref}>`))
.join(" ")}`,
]
: []),
// Security headers
`X-Originating-IP: [PRIVATE]`,
`Importance: Normal`,
"",
`--${boundary}`,
"Content-Type: text/html; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"Content-Disposition: inline",
"",
finalMessage.trim(),
];
// Process attachments if any
if (attachments?.length > 0) {
for (const file of attachments) {
// Convert File to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const base64Content = buffer.toString("base64");
emailParts.push(
`--${boundary}`,
`Content-Type: ${file.type || "application/octet-stream"}`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${file.name}"`,
"",
base64Content.match(/.{1,76}/g)?.join("\n") || base64Content,
);
}
}
// Add final boundary
emailParts.push(`--${boundary}--`);
// Join all parts with CRLF
const emailContent = emailParts.join("\r\n");
const encodedMessage = Buffer.from(emailContent).toString("base64");
await driver.create({
raw: encodedMessage,
subject,
to,
message,
attachments,
headers: additionalHeaders,
cc,
bcc
});
return { success: true };

View File

@@ -1,5 +1,5 @@
'use client';
// DEPRECATED -
import {
Form,
FormControl,

View File

@@ -2,8 +2,9 @@ import { parseAddressList, parseFrom, wasSentWithTLS } from '@/lib/email-utils';
import { type IConfig, type MailManager } from './types';
import { type gmail_v1, google } from 'googleapis';
import { EnableBrain } from '@/actions/brain';
import { type ParsedMessage } from '@/types';
import { IOutgoingMessage, Sender, type ParsedMessage } from '@/types';
import * as he from 'he';
import { createMimeMessage } from 'mimetext';
function fromBase64Url(str: string) {
return str.replace(/-/g, '+').replace(/_/g, '/');
@@ -141,7 +142,12 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
?.filter((h) => h.name?.toLowerCase() === 'cc')
.map((h) => h.value)
.filter((v) => typeof v === 'string') || [];
const cc = ccHeaders.flatMap((to) => parseAddressList(to));
const cc = ccHeaders.length > 0
? ccHeaders
.filter(header => header.trim().length > 0)
.flatMap(header => parseAddressList(header))
: null;
const receivedHeaders =
payload?.headers
@@ -170,6 +176,62 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
messageId,
};
};
const parseOutgoing = async ({ to, subject, message, attachments, headers, cc, bcc }: IOutgoingMessage) => {
const msg = createMimeMessage();
const fromEmail = config.auth?.email || 'nobody@example.com';
msg.setSender(fromEmail);
to.forEach(recipient => {
msg.setRecipient(({
addr: recipient.email,
name: recipient.name
}));
});
if (cc) msg.setCc(cc.map(recipient => ({
addr: recipient.email,
name: recipient.name
})));
if (bcc) msg.setBcc(bcc.map(recipient => ({
addr: recipient.email,
name: recipient.name
})));
msg.setSubject(subject);
msg.addMessage({
contentType: 'text/html',
data: message.trim()
});
if (headers) {
Object.keys(headers).forEach(key => {
if (headers[key]) msg.setHeader(key, headers[key]);
});
}
if (attachments?.length > 0) {
for (const file of attachments) {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const base64Content = buffer.toString("base64");
msg.addAttachment({
filename: file.name,
contentType: file.type || "application/octet-stream",
data: base64Content
});
}
}
const emailContent = msg.asRaw();
const encodedMessage = Buffer.from(emailContent).toString("base64");
return {
raw: encodedMessage,
}
}
const normalizeSearch = (folder: string, q: string) => {
// Handle special folders
if (folder === 'trash') {
@@ -449,8 +511,15 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
);
return messages;
},
create: async (data: any) => {
const res = await gmail.users.messages.send({ userId: 'me', requestBody: data });
create: async (data) => {
const { raw } = await parseOutgoing(data)
const res = await gmail.users.messages.send({
userId: 'me',
requestBody: {
raw,
threadId: data.threadId
}
});
return res.data;
},
delete: async (id: string) => {

View File

@@ -1,8 +1,8 @@
import { type InitialThread, type ParsedMessage } from '@/types';
import { type IOutgoingMessage, type InitialThread, type ParsedMessage } from '@/types';
export interface MailManager {
get(id: string): Promise<ParsedMessage[] | undefined>;
create(data: any): Promise<any>;
create(data: IOutgoingMessage): Promise<any>;
createDraft(data: any): Promise<any>;
getDraft: (id: string) => Promise<any>;
listDrafts: (q?: string, maxResults?: number, pageToken?: string) => Promise<any>;

View File

@@ -54,7 +54,6 @@ import { Markdown } from 'tiptap-markdown';
import { useReducer, useRef } from 'react';
import { useState } from 'react';
import React from 'react';
import SignatureDisplay from './signature-display';
import { TextSelection } from 'prosemirror-state';
export const defaultEditorContent = {
@@ -129,9 +128,6 @@ interface MenuBarProps {
const MenuBar = ({
onAttachmentsChange,
includeSignature,
onSignatureToggle,
hasSignature = false,
}: MenuBarProps) => {
const { editor } = useCurrentEditor();
const t = useTranslations();
@@ -208,8 +204,8 @@ const MenuBar = ({
<>
<TooltipProvider>
<div className="control-group mb-2 overflow-x-auto">
<div className="button-group ml-2 mt-1 flex flex-wrap gap-1 border-b pb-2">
<div className="mr-2 flex items-center gap-1">
<div className="button-group ml-0 mt-1 flex flex-wrap gap-1 border-b pb-2">
{/* <div className="mr-2 flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -254,7 +250,7 @@ const MenuBar = ({
</Tooltip>
</div>
<Separator orientation="vertical" className="relative right-1 top-0.5 h-6" />
<Separator orientation="vertical" className="relative right-1 top-0.5 h-6" /> */}
<div className="mr-2 flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
@@ -451,56 +447,6 @@ const MenuBar = ({
</TooltipContent>
</Tooltip>
)}
{hasSignature && onSignatureToggle && (
<>
<Separator orientation="vertical" className="relative top-0.5 h-6 mx-1.5" />
<button
onClick={() => onSignatureToggle(!includeSignature)}
className={`hover:bg-muted flex items-center space-x-1 rounded border ${includeSignature ? 'border-primary bg-primary/10 text-primary' : 'border-muted-foreground/30 text-muted-foreground'} px-2 py-1 text-xs transition-colors`}
title={includeSignature ? t('pages.createEmail.signature.disable') : t('pages.createEmail.signature.enable')}
>
{includeSignature ? (
<>
<svg
className="mr-1 h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 13V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h9" />
<path d="M18 14v4" />
<path d="M15 16l3 3 3-3" />
<path d="M2 10h20" />
<path d="M9 16l2 2 4-4" />
</svg>
<span>{t('pages.createEmail.signature.include')}</span>
</>
) : (
<>
<svg
className="mr-1 h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 13V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h9" />
<path d="M18 14v4" />
<path d="M15 16l3 3 3-3" />
<path d="M2 10h20" />
</svg>
<span>{t('pages.createEmail.signature.add')}</span>
</>
)}
</button>
</>
)}
</div>
</div>
</div>
@@ -554,7 +500,6 @@ export default function Editor({
includeSignature,
onSignatureToggle,
signature,
hasSignature,
}: EditorProps) {
const [state, dispatch] = useReducer(editorReducer, {
openNode: false,
@@ -703,7 +648,7 @@ export default function Editor({
}),
]}
ref={containerRef}
className="min-h-52 cursor-text relative"
className="cursor-text relative"
editorProps={{
handleDOMEvents: {
mousedown: (view, event) => {
@@ -720,25 +665,17 @@ export default function Editor({
view.dispatch(tr);
view.focus();
}
// Let the default handler also run
return false;
},
keydown: (view, event) => {
// Handle tab key
if (event.key === 'Tab' && !event.shiftKey) {
if (onTab && onTab()) {
event.preventDefault();
return true;
}
}
// Handle Command+Enter (Mac) or Ctrl+Enter (Windows/Linux)
// if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
// event.preventDefault();
// handleCommandEnter();
// return true;
// }
return handleCommandNavigation(event);
},
focus: () => {
@@ -769,48 +706,11 @@ export default function Editor({
slotBefore={
<MenuBar
onAttachmentsChange={onAttachmentsChange}
includeSignature={includeSignature}
onSignatureToggle={onSignatureToggle}
hasSignature={!!signature}
/>
}
slotAfter={
<>
<ImageResizer />
{signature && includeSignature && (
<div className="border-t mt-4 pt-2 text-muted-foreground">
<div className="flex items-center justify-between">
<div
className="text-xs italic pb-1"
style={{ userSelect: 'none' }}
>
{t('pages.createEmail.signature.title') || 'Signature'}
</div>
<button
onClick={() => onSignatureToggle?.(false)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 pb-1"
title={t('pages.createEmail.signature.disable') || 'Disable signature'}
>
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span>{t('pages.createEmail.signature.remove')}</span>
</button>
</div>
<div className="signature-preview rounded-md border border-dashed border-muted-foreground/20 px-3 py-2 text-sm">
<SignatureDisplay html={signature} className="w-full" />
</div>
</div>
)}
</>
}
>

View File

@@ -76,19 +76,10 @@ const Thread = memo(
message,
selectMode,
demo,
onMouseDown,
onClick,
sessionData,
isKeyboardFocused,
isInQuickActionMode,
selectedQuickActionIndex,
resetNavigation,
}: ConditionalThreadProps & {
folder?: string;
isKeyboardFocused?: boolean;
isInQuickActionMode?: boolean;
selectedQuickActionIndex?: number;
resetNavigation?: () => void;
}) => {
}: ConditionalThreadProps) => {
const [mail] = useMail();
const [searchValue] = useSearchValue();
const t = useTranslations();
@@ -162,7 +153,7 @@ const Thread = memo(
}, []);
const content = (
<div className="p-1 px-3" onMouseDown={onMouseDown ? onMouseDown(message) : undefined}>
<div className="p-1 px-3" onClick={onClick ? onClick(message) : undefined}>
{demo ? (
<div
data-thread-id={message.threadId ?? message.id}
@@ -291,7 +282,7 @@ const Thread = memo(
'text-md flex items-baseline gap-1 group-hover:opacity-100',
)}
>
<span className={cn(threadIdParam ? 'max-w-[5ch] truncate' : '')}>
<span className={cn('truncate', threadIdParam ? 'max-w-[5ch] truncate' : '')}>
{highlightText(message.sender.name, searchValue.highlight)}
</span>{' '}
{message.unread && !isMailSelected ? (
@@ -369,7 +360,7 @@ export function MailListDemo({
key={item.id}
message={item}
selectMode={'single'}
onMouseDown={(message) => () => onSelectMail && onSelectMail(message)}
onClick={(message) => () => onSelectMail && onSelectMail(message)}
/>
) : null;
})}
@@ -567,7 +558,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
return 'single';
}, [isKeyPressed]);
const handleMailMouseDown = useCallback(
const handleMailClick = useCallback(
(message: InitialThread) => () => {
handleMouseEnter(message.id);
@@ -576,21 +567,12 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
// Update local state immediately for optimistic UI
setMail((prev) => ({
...prev,
selected: messageThreadId,
replyComposerOpen: false,
forwardComposerOpen: false
}));
// Update URL param without navigation
void setThreadId(messageThreadId);
// Mark as read in background
if (message.unread) {
markAsRead({ ids: [messageThreadId] }).catch((error) => {
console.error('Failed to mark email as read:', error);
toast.error(t('common.mail.failedToMarkAsRead'));
}).then(mutate);
}
},
[handleMouseEnter, setThreadId, t, setMail],
);
@@ -641,7 +623,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
{items.map((data, index) => {
return (
<Thread
onMouseDown={handleMailMouseDown}
onClick={handleMailClick}
selectMode={getSelectMode()}
isCompact={isCompact}
sessionData={sessionData}

View File

@@ -19,6 +19,7 @@ import {
Check,
X as XIcon,
Forward,
ReplyAll,
} from 'lucide-react';
import {
cleanEmailAddress,
@@ -26,24 +27,27 @@ import {
cn,
convertJSONToHTML,
createAIJsonContent,
constructReplyBody,
} from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { UploadedFileIcon } from '@/components/create/uploaded-file-icon';
import { generateAIResponse } from '@/actions/ai-reply';
import { Separator } from '@/components/ui/separator';
import { useMail } from '@/components/mail/use-mail';
import { useSettings } from '@/hooks/use-settings';
import Editor from '@/components/create/editor';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { useSession } from '@/lib/auth-client';
import type { ParsedMessage } from '@/types';
import { useTranslations } from 'next-intl';
import { sendEmail } from '@/actions/send';
import { useForm } from 'react-hook-form';
import type { JSONContent } from 'novel';
import { toast } from 'sonner';
import type { z } from 'zod';
import { useSettings } from '@/hooks/use-settings';
import { useMail } from '@/components/mail/use-mail';
import { useThread } from '@/hooks/use-threads';
import { useQueryState } from 'nuqs';
import { Sender } from '@/types';
// Define state interfaces
interface ComposerState {
@@ -116,7 +120,6 @@ const aiReducer = (state: AIState, action: AIAction): AIState => {
};
interface ReplyComposeProps {
emailData: ParsedMessage[];
mode?: 'reply' | 'forward';
}
@@ -125,10 +128,16 @@ type FormData = {
to: string;
};
export default function ReplyCompose({ emailData, mode = 'reply' }: ReplyComposeProps) {
export default function ReplyCompose({ mode = 'reply' }: ReplyComposeProps) {
const [threadId] = useQueryState('threadId');
const { data: emailData } = useThread(threadId);
const [attachments, setAttachments] = useState<File[]>([]);
const { data: session } = useSession();
const [mail, setMail] = useMail();
const [toInput, setToInput] = useState('');
const [toEmails, setToEmails] = useState<string[]>([]);
const [includeSignature, setIncludeSignature] = useState(true);
const { settings } = useSettings();
// Use global state instead of local state
const composerIsOpen = mode === 'reply' ? mail.replyComposerOpen : mail.forwardComposerOpen;
@@ -219,35 +228,6 @@ export default function ReplyCompose({ emailData, mode = 'reply' }: ReplyCompose
}
};
const constructReplyBody = (
formattedMessage: string,
originalDate: string,
originalSender: { name?: string; email?: string } | undefined,
cleanedToEmail: string,
quotedMessage?: string,
) => {
return `
<div style="font-family: Arial, sans-serif;">
<div style="margin-bottom: 20px;">
${formattedMessage}
</div>
<div style="padding-left: 1em; margin-top: 1em; border-left: 2px solid #ccc; color: #666;">
<div style="margin-bottom: 1em;">
On ${originalDate}, ${originalSender?.name ? `${originalSender.name} ` : ''}${originalSender?.email ? `&lt;${cleanedToEmail}&gt;` : ''} wrote:
</div>
<div style="white-space: pre-wrap;">
${quotedMessage}
</div>
</div>
</div>
`;
};
const [toInput, setToInput] = useState('');
const [toEmails, setToEmails] = useState<string[]>([]);
const [includeSignature, setIncludeSignature] = useState(true);
const { settings } = useSettings();
const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
@@ -285,6 +265,7 @@ export default function ReplyCompose({ emailData, mode = 'reply' }: ReplyCompose
const handleSendEmail = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!emailData) return;
setIsSubmitting(true);
try {
const originalEmail = emailData[0];
@@ -294,61 +275,61 @@ export default function ReplyCompose({ emailData, mode = 'reply' }: ReplyCompose
throw new Error('Active connection email not found');
}
if (!originalEmail) {
throw new Error('Original email not found');
}
// Handle subject based on mode
const subject =
mode === 'forward'
? `Fwd: ${originalEmail?.subject || ''}`
: originalEmail?.subject?.startsWith('Re:')
? `Fwd: ${originalEmail.subject || ''}`
: originalEmail.subject?.startsWith('Re:')
? originalEmail.subject
: `Re: ${originalEmail?.subject || ''}`;
// For forwarding, use the entered email addresses
const recipients =
const recipients: Sender[] =
mode === 'forward'
? toEmails.join(', ')
? toEmails.map((email) => ({ email, name: 'User' }))
: [
// Original sender
...(originalEmail?.sender?.email
? [cleanEmailAddress(originalEmail.sender.email)]
: []),
// All TO recipients
...(originalEmail?.to?.map((to) => cleanEmailAddress(to.email)) || []),
// All CC recipients
...(originalEmail?.cc?.map((cc) => cleanEmailAddress(cc.email)) || []),
]
.filter(Boolean)
.filter(
(email, index, self) =>
self.indexOf(email) === index && email.toLowerCase() !== userEmail,
)
.join(', ');
{
email: cleanEmailAddress(originalEmail.sender.email),
name: originalEmail.sender.name ? originalEmail.sender.name : ''
}
]
const cc: Sender[] | null = originalEmail.cc ? originalEmail.cc.map((to) => ({
email: cleanEmailAddress(to.email),
name: to.name ? to.name : ''
})) : null
if (!recipients) {
throw new Error('No valid recipients found');
}
const messageId = originalEmail?.messageId;
const threadId = originalEmail?.threadId;
const messageId = originalEmail.messageId;
const threadId = originalEmail.threadId;
const formattedMessage = form.getValues('messageContent');
const originalDate = new Date(originalEmail?.receivedOn || '').toLocaleString();
const quotedMessage = originalEmail?.decodedBody;
const originalDate = new Date(originalEmail.receivedOn || '').toLocaleString();
const quotedMessage = originalEmail.decodedBody;
const replyBody = constructReplyBody(
formattedMessage,
originalDate,
originalEmail?.sender,
originalEmail.sender,
recipients,
quotedMessage,
);
const inReplyTo = messageId;
const existingRefs = originalEmail?.references?.split(' ') || [];
const existingRefs = originalEmail.references?.split(' ') || [];
const references = [...existingRefs, originalEmail?.inReplyTo, cleanEmailAddress(messageId)]
.filter(Boolean)
.join(' ');
await sendEmail({
to: recipients,
cc: cc ?? undefined,
subject,
message: replyBody,
attachments,
@@ -357,7 +338,6 @@ export default function ReplyCompose({ emailData, mode = 'reply' }: ReplyCompose
References: references,
'Thread-Id': threadId ?? '',
},
includeSignature: includeSignature && settings?.signature?.enabled,
});
form.reset();
@@ -378,14 +358,6 @@ export default function ReplyCompose({ emailData, mode = 'reply' }: ReplyCompose
}
};
// Add a useEffect to focus the editor when the composer opens
// Initialize signature toggle from settings
useEffect(() => {
if (settings?.signature) {
setIncludeSignature(settings.signature.includeByDefault);
}
}, [settings]);
useEffect(() => {
if (composerIsOpen) {
// Give the editor time to render before focusing
@@ -455,20 +427,21 @@ export default function ReplyCompose({ emailData, mode = 'reply' }: ReplyCompose
const isMessageEmpty =
!form.getValues('messageContent') ||
form.getValues('messageContent') ===
JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [],
},
],
});
JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [],
},
],
});
// Check if form is valid for submission
const isFormValid = !isMessageEmpty || attachments.length > 0;
const handleAIButtonClick = async () => {
if (!emailData) return;
aiDispatch({ type: 'SET_LOADING', payload: true });
try {
// Extract relevant information from the email thread for context
@@ -561,6 +534,7 @@ ${email.decodedBody || 'No content'}
// Helper function to render the header content based on mode
const renderHeaderContent = () => {
if (!emailData) return;
if (mode === 'forward') {
return (
<div className="flex items-center gap-2">
@@ -599,10 +573,6 @@ ${email.decodedBody || 'No content'}
type="email"
className="text-md relative left-[3px] min-w-[120px] flex-1 bg-transparent placeholder:text-[#616161] placeholder:opacity-50 focus:outline-none"
placeholder={toEmails.length ? '' : t('pages.createEmail.example')}
value={toInput}
onChange={(e) => setToInput(e.target.value)}
onKeyDown={handleEmailInputKeyDown}
onBlur={handleEmailInputBlur}
/>
</div>
</div>
@@ -629,7 +599,6 @@ ${email.decodedBody || 'No content'}
handleAddEmail(toInput);
} else if (e.key === 'Backspace' && !toInput && toEmails.length > 0) {
setToEmails((emails) => emails.slice(0, -1));
form.setValue('to', toEmails.join(', '));
}
};
@@ -668,10 +637,9 @@ ${email.decodedBody || 'No content'}
className="flex h-12 w-full items-center justify-center gap-2 rounded-md"
variant="outline"
>
<Reply className="h-4 w-4" />
<ReplyAll className="h-4 w-4" />
<span>
{t('common.replyCompose.replyTo')}{' '}
{emailData[emailData.length - 1]?.sender?.name || t('common.replyCompose.thisEmail')}
{t('common.replyCompose.replyTo')} All
</span>
</Button>
</div>
@@ -679,7 +647,7 @@ ${email.decodedBody || 'No content'}
}
return null;
}
if (!emailData) return;
return (
<div className="bg-offsetLight dark:bg-offsetDark w-full px-2">
<form
@@ -735,7 +703,7 @@ ${email.decodedBody || 'No content'}
onCommandEnter={handleCommandEnter}
onTab={handleTabAccept}
className={cn(
'sm:max-w-[600px] md:max-w-[2050px]',
'max-w-[600px] md:max-w-[100vw] overflow-hidden',
aiState.showOptions
? 'rounded-md border border-dotted border-blue-200 bg-blue-50/30 p-1 dark:border-blue-800 dark:bg-blue-950/30'
: 'border border-transparent p-1',
@@ -760,13 +728,6 @@ ${email.decodedBody || 'No content'}
name: emailData[0]?.sender?.name,
email: emailData[0]?.sender?.email,
}}
includeSignature={includeSignature}
onSignatureToggle={setIncludeSignature}
signature={
settings?.signature?.enabled && settings?.signature?.content
? settings.signature.content
: undefined
}
/>
<div
className="h-2 w-full cursor-ns-resize hover:bg-gray-200 dark:hover:bg-gray-700"

View File

@@ -16,7 +16,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useThread, useThreads } from '@/hooks/use-threads';
import { MailDisplaySkeleton } from './mail-skeleton';
import { Button } from '@/components/ui/button';
import { markAsUnread } from '@/actions/mail';
import { markAsRead, markAsUnread } from '@/actions/mail';
import { modifyLabels } from '@/actions/mail';
import { useStats } from '@/hooks/use-stats';
import ThreadSubject from './thread-subject';
@@ -81,12 +81,9 @@ export function ThreadDemo({ messages, isMobile }: ThreadDisplayProps) {
</div>
</ScrollArea>
<div className="relative flex-shrink-0 md:top-1">
{messages ? (
<ReplyCompose
emailData={messages}
<ReplyCompose
mode={mail.forwardComposerOpen ? 'forward' : 'reply'}
/>
) : null}
/>
</div>
</div>
</div>
@@ -135,7 +132,7 @@ function ThreadActionButton({
}
export function ThreadDisplay({ threadParam, onClose, isMobile, id }: ThreadDisplayProps) {
const { data: emailData, isLoading } = useThread(id ?? null);
const { data: emailData, isLoading, mutate: mutateThread } = useThread(id ?? null);
const { mutate: mutateThreads } = useThreads();
const searchParams = useSearchParams();
const [isMuted, setIsMuted] = useState(false);
@@ -147,6 +144,25 @@ export function ThreadDisplay({ threadParam, onClose, isMobile, id }: ThreadDisp
const threadIdParam = searchParams.get('threadId');
const threadId = threadParam ?? threadIdParam ?? '';
/**
* Mark email as read if it's unread, if there are no unread emails, mark the current thread as read
*/
useEffect(() => {
if (!emailData || !id) return;
const unreadEmails = emailData.filter(e => e.unread);
if (unreadEmails.length === 0) {
markAsRead({ ids: [id] }).catch((error) => {
console.error('Failed to mark email as read:', error);
toast.error(t('common.mail.failedToMarkAsRead'));
}).then(() => Promise.all([mutateThread(), mutateThreads()]))
} else {
const ids = [id, ...unreadEmails.map(e => e.id)]
markAsRead({ ids }).catch((error) => {
console.error('Failed to mark email as read:', error);
toast.error(t('common.mail.failedToMarkAsRead'));
}).then(() => Promise.all([mutateThread(), mutateThreads()]))
}
}, [emailData, id])
const isInArchive = folder === FOLDERS.ARCHIVE;
const isInSpam = folder === FOLDERS.SPAM;
@@ -407,7 +423,6 @@ export function ThreadDisplay({ threadParam, onClose, isMobile, id }: ThreadDisp
isFullscreen ? 'mb-2' : ''
)}>
<ReplyCompose
emailData={emailData}
mode={mail.forwardComposerOpen ? 'forward' : 'reply'}
/>
</div>

View File

@@ -5,6 +5,7 @@ import { twMerge } from 'tailwind-merge';
import { JSONContent } from 'novel';
import LZString from 'lz-string';
import axios from 'axios';
import { Sender } from '@/types';
export const FOLDERS = {
SPAM: 'spam',
@@ -360,3 +361,42 @@ export const getEmailLogo = (email: string) => {
export const generateConversationId = (): string => {
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
};
export const contentToHTML = (content: string) => `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body style="margin: 0; padding: 0;">
${content}
</body></html>`;
export const constructReplyBody = (
formattedMessage: string,
originalDate: string,
originalSender: Sender | undefined,
otherRecipients: Sender[],
quotedMessage?: string,
) => {
const senderName = originalSender?.name || originalSender?.email || 'Unknown Sender';
const recipientEmails = otherRecipients.map(r => r.email).join(', ');
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<div style="margin-bottom: 20px; line-height: 1.5;">
${formattedMessage}
</div>
<div style="padding-left: 16px; margin-top: 20px; border-left: 3px solid #e2e8f0; color: #64748b;">
<div style="margin-bottom: 12px; font-size: 14px;">
On ${originalDate}, ${senderName} ${recipientEmails ? `&lt;${recipientEmails}&gt;` : ''} wrote:
</div>
<div style="white-space: pre-wrap; line-height: 1.5;">
${quotedMessage || ''}
</div>
</div>
</div>
`;
};

View File

@@ -78,6 +78,7 @@
"jotai": "2.12.1",
"lucide-react": "0.474.0",
"lz-string": "1.5.0",
"mimetext": "^3.0.27",
"motion": "12.4.7",
"next": "15.2.3",
"next-intl": "3.26.5",

View File

@@ -42,7 +42,7 @@ export interface ParsedMessage {
tags: string[];
sender: Sender;
to: Sender[];
cc: Sender[];
cc: Sender[] | null;
tls: boolean;
listUnsubscribe?: string;
listUnsubscribePost?: string;
@@ -99,8 +99,13 @@ export type ThreadProps = {
message: InitialThread;
selectMode: MailSelectMode;
// TODO: enforce types instead of sprinkling "any"
onMouseDown?: (message: InitialThread) => () => any;
onClick?: (message: InitialThread) => () => void;
isCompact?: boolean;
folder?: string;
isKeyboardFocused?: boolean;
isInQuickActionMode?: boolean;
selectedQuickActionIndex?: number;
resetNavigation?: () => void;
};
export type ConditionalThreadProps = ThreadProps &
@@ -108,3 +113,16 @@ export type ConditionalThreadProps = ThreadProps &
| { demo?: true; sessionData?: { userId: string; connectionId: string | null } }
| { demo?: false; sessionData: { userId: string; connectionId: string | null } }
);
export interface IOutgoingMessage {
to: Sender[];
cc?: Sender[];
bcc?: Sender[];
subject: string
message: string
attachments: any[]
headers: Record<string, string>
threadId?: string
}

View File

@@ -89,6 +89,7 @@
"jotai": "2.12.1",
"lucide-react": "0.474.0",
"lz-string": "1.5.0",
"mimetext": "^3.0.27",
"motion": "12.4.7",
"next": "15.2.3",
"next-intl": "3.26.5",
@@ -189,6 +190,8 @@
"@babel/runtime": ["@babel/runtime@7.27.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw=="],
"@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.27.0", "", { "dependencies": { "core-js-pure": "^3.30.2", "regenerator-runtime": "^0.14.0" } }, "sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew=="],
"@better-auth/utils": ["@better-auth/utils@0.2.3", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-Ap1GaSmo6JYhJhxJOpUB0HobkKPTNzfta+bLV89HfpyCAHN7p8ntCrmNFHNAVD0F6v0mywFVEUg1FUhNCc81Rw=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="],
@@ -925,6 +928,8 @@
"core-js": ["core-js@3.41.0", "", {}, "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA=="],
"core-js-pure": ["core-js-pure@3.41.0", "", {}, "sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1355,6 +1360,8 @@
"jotai": ["jotai@2.12.1", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-VUW0nMPYIru5g89tdxwr9ftiVdc/nGV9jvHISN8Ucx+m1vI9dBeHemfqYzEuw5XSkmYjD/MEyApN9k6yrATsZQ=="],
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
"js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="],
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
@@ -1507,6 +1514,8 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimetext": ["mimetext@3.0.27", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@babel/runtime-corejs3": "^7.26.0", "js-base64": "^3.7.7", "mime-types": "^2.1.35" } }, "sha512-mUhWAsZD1N/K6dbN4+a5Yq78OPnYQw1ubOSMasBntsLQ2S7KVNlvDEA8dwpr4a7PszWMzeslKahAprtwYMgaBA=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],

View File

@@ -7,11 +7,6 @@ export const defaultUserSettings = {
externalImages: true,
customPrompt: "",
trustedSenders: [],
signature: {
enabled: false,
content: "",
includeByDefault: true,
},
} satisfies UserSettings;
export const userSettingsSchema = z.object({
@@ -20,12 +15,7 @@ export const userSettingsSchema = z.object({
dynamicContent: z.boolean(),
externalImages: z.boolean(),
customPrompt: z.string(),
trustedSenders: z.string().array(),
signature: z.object({
enabled: z.boolean(),
content: z.string(),
includeByDefault: z.boolean(),
}),
trustedSenders: z.string().array().optional(),
});
export type UserSettings = z.infer<typeof userSettingsSchema>