mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-30 07:46:15 +00:00
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:
@@ -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 };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
|
||||
// DEPRECATED -
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? `<${cleanedToEmail}>` : ''} 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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? `<${recipientEmails}>` : ''} wrote:
|
||||
</div>
|
||||
<div style="white-space: pre-wrap; line-height: 1.5;">
|
||||
${quotedMessage || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
9
bun.lock
9
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=="],
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user