mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-29 07:16:19 +00:00
@@ -6,11 +6,10 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/zerodotemail"
|
||||
# Change this to a random string, use `openssl rand -hex 32` to generate a 32 character string
|
||||
BETTER_AUTH_SECRET=my-better-auth-secret
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
|
||||
|
||||
COOKIE_DOMAIN="localhost"
|
||||
|
||||
# Change to your project's client ID and secret, these work with localhost:3000 and localhost:3001
|
||||
# Change to your project's client ID and secret, these work with localhost:8787
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
|
||||
1
.github/CONTRIBUTING.md
vendored
1
.github/CONTRIBUTING.md
vendored
@@ -32,6 +32,7 @@ Thank you for your interest in contributing to 0.email! We're excited to have yo
|
||||
- Clone the repository and install dependencies: `bun install`
|
||||
- Start the database locally: `bun docker:up`
|
||||
- Copy `.env.example` to `.env` in project root
|
||||
- Setup cloudflare with `bun run cf-install`, you will need to run this everytime there is a `.env` change
|
||||
- Set up your Google OAuth credentials (see [README.md](../README.md))
|
||||
- Initialize the database: `bun db:push`
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ You can set up Zero in two ways:
|
||||
cp .env.example .env
|
||||
```
|
||||
- Configure your environment variables (see below)
|
||||
- Setup cloudflare with `bun run cf-install`, you will need to run this everytime there is a `.env` change
|
||||
- Start the database with the provided docker compose setup: `bun docker:up`
|
||||
- Initialize the database: `bun db:push`
|
||||
|
||||
@@ -148,7 +149,7 @@ bun install
|
||||
- Create OAuth 2.0 credentials (Web application type)
|
||||
- Add authorized redirect URIs:
|
||||
- Development:
|
||||
- `http://localhost:3000/api/auth/callback/google`
|
||||
- `http://localhost:8787/api/auth/callback/google`
|
||||
- Production:
|
||||
- `https://your-production-url/api/auth/callback/google`
|
||||
- Add to `.env`:
|
||||
@@ -210,7 +211,7 @@ Zero uses PostgreSQL for storing data. Here's how to set it up:
|
||||
|
||||
2. **Set Up Database Connection**
|
||||
|
||||
Make sure your database connection string is in `.env` file.
|
||||
Make sure your database connection string is in `.env` file. And you have ran `bun run cf-install` to sync the latest env.
|
||||
|
||||
For local development use:
|
||||
|
||||
|
||||
@@ -104,11 +104,14 @@ export function EmailComposer({
|
||||
const [draftId, setDraftId] = useState<string | null>(urlDraftId ?? null);
|
||||
const [aiGeneratedMessage, setAiGeneratedMessage] = useState<string | null>(null);
|
||||
const [aiIsLoading, setAiIsLoading] = useState(false);
|
||||
const [isGeneratingSubject, setIsGeneratingSubject] = useState(false);
|
||||
|
||||
const trpc = useTRPC();
|
||||
const { mutateAsync: aiCompose } = useMutation(trpc.ai.compose.mutationOptions());
|
||||
const { mutateAsync: createDraft } = useMutation(trpc.drafts.create.mutationOptions());
|
||||
|
||||
const { mutateAsync: generateEmailSubject } = useMutation(
|
||||
trpc.ai.generateEmailSubject.mutationOptions(),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (isComposeOpen === 'true' && toInputRef.current) {
|
||||
toInputRef.current.focus();
|
||||
@@ -355,6 +358,13 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateSubject = async () => {
|
||||
setIsGeneratingSubject(true);
|
||||
const { subject } = await generateEmailSubject({ message: editor.getText() });
|
||||
setValue('subject', subject);
|
||||
setIsGeneratingSubject(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (urlDraftId !== draftId) {
|
||||
setDraftId(urlDraftId ?? null);
|
||||
@@ -661,11 +671,31 @@ export function EmailComposer({
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className=""
|
||||
onClick={handleGenerateSubject}
|
||||
disabled={isLoading || isGeneratingSubject}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2.5 pl-0.5">
|
||||
<div className="flex h-5 items-center justify-center gap-1 rounded-sm">
|
||||
{isGeneratingSubject ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin fill-black dark:fill-white" />
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5 fill-black dark:fill-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div className="relative -bottom-1 flex flex-col items-start justify-start gap-2 self-stretch border-t bg-[#FFFFFF] px-3 py-3 outline-white/5 dark:bg-[#202020]">
|
||||
<div className="flex flex-col gap-2.5 self-stretch">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2.5 self-stretch',
|
||||
aiGeneratedMessage !== null ? 'blur-sm' : '',
|
||||
)}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
@@ -853,9 +883,8 @@ export function EmailComposer({
|
||||
<button
|
||||
className="flex h-7 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-md border border-[#8B5CF6] pl-1.5 pr-2 dark:bg-[#252525]"
|
||||
onClick={async () => {
|
||||
if (!toEmails.length || !subjectInput.trim()) {
|
||||
toast.error('Please enter a recipient and subject');
|
||||
return;
|
||||
if (!subjectInput.trim()) {
|
||||
await handleGenerateSubject();
|
||||
}
|
||||
setAiGeneratedMessage(null);
|
||||
await handleAiGenerate();
|
||||
@@ -894,7 +923,7 @@ export function EmailComposer({
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
disabled
|
||||
className="flex hidden h-7 items-center gap-0.5 overflow-hidden rounded-md bg-white/5 px-1.5 shadow-sm hover:bg-white/10 disabled:opacity-50 md:flex"
|
||||
className="flex h-7 items-center gap-0.5 overflow-hidden rounded-md bg-white/5 px-1.5 shadow-sm hover:bg-white/10 disabled:opacity-50 md:flex"
|
||||
>
|
||||
{messageLength < 50 && <ShortStack className="h-3 w-3 fill-[#9A9A9A]" />}
|
||||
{messageLength >= 50 && messageLength < 200 && (
|
||||
@@ -970,7 +999,7 @@ const ContentPreview = ({
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="absolute bottom-full right-0 z-30 w-[400px] overflow-hidden rounded-xl border bg-white shadow-md dark:bg-black"
|
||||
className="dark:bg-subtleBlack absolute bottom-full right-0 z-30 w-[400px] overflow-hidden rounded-xl border bg-white p-1 shadow-md"
|
||||
>
|
||||
<div
|
||||
className="max-h-60 min-h-[150px] overflow-y-auto rounded-md p-1 text-sm"
|
||||
|
||||
@@ -172,7 +172,7 @@ const Thread = memo(
|
||||
const [searchValue, setSearchValue] = useSearchValue();
|
||||
const t = useTranslations();
|
||||
const { folder } = useParams<{ folder: string }>();
|
||||
const [{ refetch: refetchThreads }] = useThreads();
|
||||
const [{ refetch: refetchThreads }, threads] = useThreads();
|
||||
const [threadId] = useQueryState('threadId');
|
||||
const [, setBackgroundQueue] = useAtom(backgroundQueueAtom);
|
||||
const { refetch: refetchStats } = useStats();
|
||||
@@ -181,6 +181,9 @@ const Thread = memo(
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: toggleStar } = useMutation(trpc.mail.toggleStar.mutationOptions());
|
||||
const [id, setThreadId] = useQueryState('threadId');
|
||||
const [activeReplyId, setActiveReplyId] = useQueryState('activeReplyId');
|
||||
const [focusedIndex, setFocusedIndex] = useAtom(focusedIndexAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (getThreadData?.latest?.tags) {
|
||||
@@ -206,6 +209,21 @@ const Thread = memo(
|
||||
[getThreadData, message.id, isStarred, refetchThreads, t],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(
|
||||
(id: string) => {
|
||||
if (!id || !threads.length || focusedIndex === null) return setThreadId(null);
|
||||
if (focusedIndex < threads.length - 1) {
|
||||
const nextThread = threads[focusedIndex];
|
||||
if (nextThread) {
|
||||
setThreadId(nextThread.id);
|
||||
setActiveReplyId(null);
|
||||
setFocusedIndex(focusedIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
[threads, id, focusedIndex],
|
||||
);
|
||||
|
||||
const moveThreadTo = useCallback(
|
||||
async (destination: ThreadDestination) => {
|
||||
if (!message.id) return;
|
||||
@@ -215,7 +233,7 @@ const Thread = memo(
|
||||
destination,
|
||||
});
|
||||
setBackgroundQueue({ type: 'add', threadId: `thread:${message.id}` });
|
||||
|
||||
handleNext(message.id);
|
||||
toast.success(
|
||||
destination === 'inbox'
|
||||
? t('common.actions.movedToInbox')
|
||||
@@ -590,7 +608,7 @@ const Thread = memo(
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 line-clamp-1 max-w-[25ch] sm:max-w-[50ch] text-sm text-[#8C8C8C] md:max-w-[40ch]',
|
||||
'mt-1 line-clamp-1 max-w-[25ch] text-sm text-[#8C8C8C] sm:max-w-[50ch] md:max-w-[40ch]',
|
||||
)}
|
||||
>
|
||||
{highlightText(latestMessage.subject, searchValue.highlight)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { env, WorkerEntrypoint } from 'cloudflare:workers';
|
||||
import { mailtoHandler } from './routes/mailto-handler';
|
||||
import type { HonoContext, HonoVariables } from './ctx';
|
||||
import { routePartykitRequest } from 'partyserver';
|
||||
import { partyserverMiddleware } from 'hono-party';
|
||||
import { trpcServer } from '@hono/trpc-server';
|
||||
import { DurableMailbox } from './lib/party';
|
||||
import { chatHandler } from './routes/chat';
|
||||
import type { HonoVariables } from './ctx';
|
||||
import { createAuth } from './lib/auth';
|
||||
import { createDb } from '@zero/db';
|
||||
import { appRouter } from './trpc';
|
||||
@@ -18,7 +18,7 @@ const api = new Hono<{ Variables: HonoVariables; Bindings: Env }>()
|
||||
.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: (_, c: HonoContext) => env.NEXT_PUBLIC_APP_URL,
|
||||
origin: () => env.NEXT_PUBLIC_APP_URL,
|
||||
credentials: true,
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
}),
|
||||
@@ -76,14 +76,14 @@ const app = new Hono<{ Variables: HonoVariables; Bindings: Env }>()
|
||||
}),
|
||||
);
|
||||
|
||||
export default class extends WorkerEntrypoint {
|
||||
export default class extends WorkerEntrypoint<typeof env> {
|
||||
fetch(request: Request): Response | Promise<Response> {
|
||||
if (request.url.includes('/zero/durable-mailbox')) {
|
||||
return routePartykitRequest(request, env as any, {
|
||||
return routePartykitRequest(request, env as unknown as Record<string, unknown>, {
|
||||
prefix: 'zero',
|
||||
}) as Promise<Response>;
|
||||
}
|
||||
return app.fetch(request);
|
||||
return app.fetch(request, this.env, this.ctx);
|
||||
}
|
||||
|
||||
public async notifyUser({
|
||||
|
||||
@@ -105,6 +105,28 @@ export const compose = activeConnectionProcedure
|
||||
};
|
||||
});
|
||||
|
||||
export const generateEmailSubject = activeConnectionProcedure
|
||||
.input(
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { activeConnection } = ctx;
|
||||
const { message } = input;
|
||||
|
||||
const writingStyleMatrix = await getWritingStyleMatrixForConnectionId({
|
||||
connectionId: activeConnection.id,
|
||||
c: ctx.c,
|
||||
});
|
||||
|
||||
const subject = await generateSubject(message, writingStyleMatrix?.style as WritingStyleMatrix);
|
||||
|
||||
return {
|
||||
subject,
|
||||
};
|
||||
});
|
||||
|
||||
const MessagePrompt = ({
|
||||
from,
|
||||
to,
|
||||
@@ -196,3 +218,44 @@ const EmailAssistantPrompt = ({
|
||||
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
const generateSubject = async (message: string, styleProfile?: WritingStyleMatrix | null) => {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push('# Email Subject Generation Task');
|
||||
if (styleProfile) {
|
||||
parts.push('## Style Profile');
|
||||
parts.push(`\`\`\`json
|
||||
${JSON.stringify(styleProfile, null, 2)}
|
||||
\`\`\``);
|
||||
}
|
||||
|
||||
parts.push('## Email Content');
|
||||
parts.push(escapeXml(message));
|
||||
parts.push('');
|
||||
parts.push(
|
||||
'Generate a concise, clear subject line that summarizes the main point of the email. The subject should be professional and under 100 characters.',
|
||||
);
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai('gpt-4o'),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are an email subject line generator. Generate a concise, clear subject line that summarizes the main point of the email. The subject should be professional and under 100 characters.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: parts.join('\n\n'),
|
||||
},
|
||||
],
|
||||
maxTokens: 50,
|
||||
temperature: 0.3,
|
||||
frequencyPenalty: 0.1,
|
||||
presencePenalty: 0.1,
|
||||
maxRetries: 1,
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { compose, generateEmailSubject } from './compose';
|
||||
import { generateSearchQuery } from './search';
|
||||
import { router } from '../../trpc';
|
||||
import { compose } from './compose';
|
||||
|
||||
export const aiRouter = router({
|
||||
generateSearchQuery: generateSearchQuery,
|
||||
compose: compose,
|
||||
generateEmailSubject: generateEmailSubject,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user