From 6cbd6f7b90fb49ece9fbcc9e7714f55e00a901da Mon Sep 17 00:00:00 2001 From: Aj Wazzan Date: Wed, 9 Apr 2025 00:09:32 -0700 Subject: [PATCH] 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. --- apps/mail/actions/send.ts | 140 ++-------------- .../app/(routes)/settings/signatures/page.tsx | 2 +- apps/mail/app/api/driver/google.ts | 77 ++++++++- apps/mail/app/api/driver/types.ts | 4 +- apps/mail/components/create/editor.tsx | 110 +------------ apps/mail/components/mail/mail-list.tsx | 32 +--- apps/mail/components/mail/reply-composer.tsx | 153 +++++++----------- apps/mail/components/mail/thread-display.tsx | 31 +++- apps/mail/lib/utils.ts | 40 +++++ apps/mail/package.json | 1 + apps/mail/types/index.ts | 22 ++- bun.lock | 9 ++ packages/db/src/user_settings_default.ts | 12 +- 13 files changed, 254 insertions(+), 379 deletions(-) diff --git a/apps/mail/actions/send.ts b/apps/mail/actions/send.ts index e7733da7f..bd99f032d 100644 --- a/apps/mail/actions/send.ts +++ b/apps/mail/actions/send.ts @@ -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; - includeSignature?: boolean; + headers?: Record; + 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) => ` - - - - - - - -${content} -${signature ? ` -
-${signature} -
` : ''} - -`; - - 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 }; diff --git a/apps/mail/app/(routes)/settings/signatures/page.tsx b/apps/mail/app/(routes)/settings/signatures/page.tsx index cc0b3af76..92fbccb81 100644 --- a/apps/mail/app/(routes)/settings/signatures/page.tsx +++ b/apps/mail/app/(routes)/settings/signatures/page.tsx @@ -1,5 +1,5 @@ 'use client'; - +// DEPRECATED - import { Form, FormControl, diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts index 8f808040e..0e7e9ff1b 100644 --- a/apps/mail/app/api/driver/google.ts +++ b/apps/mail/app/api/driver/google.ts @@ -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 => { ?.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 => { 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 => { ); 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) => { diff --git a/apps/mail/app/api/driver/types.ts b/apps/mail/app/api/driver/types.ts index 23d643691..f3129ad76 100644 --- a/apps/mail/app/api/driver/types.ts +++ b/apps/mail/app/api/driver/types.ts @@ -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; - create(data: any): Promise; + create(data: IOutgoingMessage): Promise; createDraft(data: any): Promise; getDraft: (id: string) => Promise; listDrafts: (q?: string, maxResults?: number, pageToken?: string) => Promise; diff --git a/apps/mail/components/create/editor.tsx b/apps/mail/components/create/editor.tsx index 7fc92b1ef..3e79fccc1 100644 --- a/apps/mail/components/create/editor.tsx +++ b/apps/mail/components/create/editor.tsx @@ -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 = ({ <>
-
-
+
+ {/*
- + */}
@@ -451,56 +447,6 @@ const MenuBar = ({ )} - - {hasSignature && onSignatureToggle && ( - <> - - - - )}
@@ -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={ } slotAfter={ <> - {signature && includeSignature && ( -
-
-
- {t('pages.createEmail.signature.title') || 'Signature'} -
- -
-
- -
-
- )} } > diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index f751beb21..013a4bc36 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -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 = ( -
+
{demo ? (
- + {highlightText(message.sender.name, searchValue.highlight)} {' '} {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 ( { }; 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([]); const { data: session } = useSession(); const [mail, setMail] = useMail(); + const [toInput, setToInput] = useState(''); + const [toEmails, setToEmails] = useState([]); + 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 ` -
-
- ${formattedMessage} -
-
-
- On ${originalDate}, ${originalSender?.name ? `${originalSender.name} ` : ''}${originalSender?.email ? `<${cleanedToEmail}>` : ''} wrote: -
-
- ${quotedMessage} -
-
-
- `; - }; - - const [toInput, setToInput] = useState(''); - const [toEmails, setToEmails] = useState([]); - 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) => { 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 (
@@ -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} />
@@ -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" > - + - {t('common.replyCompose.replyTo')}{' '} - {emailData[emailData.length - 1]?.sender?.name || t('common.replyCompose.thisEmail')} + {t('common.replyCompose.replyTo')} All
@@ -679,7 +647,7 @@ ${email.decodedBody || 'No content'} } return null; } - + if (!emailData) return; return (
- {messages ? ( - - ) : null} + />
@@ -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' : '' )}>
diff --git a/apps/mail/lib/utils.ts b/apps/mail/lib/utils.ts index 447f537c8..7a833547d 100644 --- a/apps/mail/lib/utils.ts +++ b/apps/mail/lib/utils.ts @@ -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) => ` + + + + + + + +${content} +`; + + +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 ` +
+
+ ${formattedMessage} +
+
+
+ On ${originalDate}, ${senderName} ${recipientEmails ? `<${recipientEmails}>` : ''} wrote: +
+
+ ${quotedMessage || ''} +
+
+
+ `; +}; \ No newline at end of file diff --git a/apps/mail/package.json b/apps/mail/package.json index 87ae0641c..544617ca0 100644 --- a/apps/mail/package.json +++ b/apps/mail/package.json @@ -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", diff --git a/apps/mail/types/index.ts b/apps/mail/types/index.ts index cf93cc233..8ff95f407 100644 --- a/apps/mail/types/index.ts +++ b/apps/mail/types/index.ts @@ -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 + threadId?: string +} \ No newline at end of file diff --git a/bun.lock b/bun.lock index ee145a83d..bb37130ee 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/db/src/user_settings_default.ts b/packages/db/src/user_settings_default.ts index b43b56ed1..fba07c6de 100644 --- a/packages/db/src/user_settings_default.ts +++ b/packages/db/src/user_settings_default.ts @@ -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 \ No newline at end of file