diff --git a/README.md b/README.md index 23ca345..5a83b04 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,15 @@ MailBox is a web application that allows you to send and receive emails serverle - [x] 接收邮件功能 (Cloudflare Mail Workers -> Next.js -> MongoDB) - [x] 注册功能 (服务端注册条件控制) - [x] 单条邮件阅读组件 +- [x] 发送邮件功能 (Resend) - [ ] 夜间模式 - [ ] 个人资料页面 -- [ ] 发送邮件功能 (Resend) - [ ] 已发送邮件页面 - [ ] 支持 Markdown 写邮件 (Marked) - [ ] 找回密码功能 (向备用邮箱发送验证码) - [ ] AI 总结邮件内容生成邮件摘要 (Cloudflare Workers AI) +- [ ] 附件支持 +- [ ] 邮件收藏 ## Usage ### 1 Get MongoDB Atlas URI diff --git a/app/(dashboard)/inbox/action.ts b/app/(dashboard)/inbox/action.ts index 5198808..4e7cd7f 100644 --- a/app/(dashboard)/inbox/action.ts +++ b/app/(dashboard)/inbox/action.ts @@ -11,6 +11,7 @@ const inbox = db.collection('inbox') export type Mail = { _id: string from: string + fromName: string to: string subject: string content: string @@ -47,7 +48,8 @@ export async function getEmail(email: string, password: string, _id: string): Pr } return { _id: data._id.toString(), - from: data.workers.from, + fromName: data.from.name, + from: data.from.address, to: data.workers.to, subject: data.subject, content: data.html, @@ -64,12 +66,13 @@ export async function getMails(email: string, password: string, limit: number, s return '401' } // 获取邮箱列表 - const data = inbox.find({ 'workers.to': email }).sort({ date: -1 }).skip(skip).limit(limit).project({ text: 1, subject: 1, date: 1, workers: 1 }) + const data = inbox.find({ 'workers.to': email }).sort({ date: -1 }).skip(skip).limit(limit).project({ text: 1, subject: 1, date: 1, workers: 1, from: 1 }) const mails: Mail[] = [] for await (const doc of data) { mails.push({ _id: doc._id.toString(), - from: doc.workers.from, + from: doc.from.address, + fromName: doc.from.name, to: doc.workers.to, subject: doc.subject, content: doc.text.slice(0, 100), diff --git a/app/(dashboard)/inbox/page.tsx b/app/(dashboard)/inbox/page.tsx index b09964e..d8fa6d0 100644 --- a/app/(dashboard)/inbox/page.tsx +++ b/app/(dashboard)/inbox/page.tsx @@ -12,6 +12,7 @@ export default function Inbox() { const mailsPerPage = 20 const emailRef = useRef('') const passwordRef = useRef('') + const [username, setUsername] = useState('') const router = useRouter() const [messageAPI, contextHolder] = message.useMessage() @@ -40,7 +41,7 @@ export default function Inbox() { } // 当前显示的邮件 - const loadingEmail: Mail = { _id: '', from: '', to: '', subject: '加载中...', content: '', date: '', attachments: [] } + const loadingEmail: Mail = { _id: '', fromName: '', from: '', to: '', subject: '加载中...', content: '', date: '', attachments: [] } const [email, setEmail] = useState(loadingEmail) const [open, setOpen] = useState(false) const [loading, setLoading] = useState(true) @@ -93,6 +94,7 @@ export default function Inbox() { useEffect(() => { emailRef.current = localStorage.getItem('email') ?? sessionStorage.getItem('email') ?? '' passwordRef.current = localStorage.getItem('password') ?? sessionStorage.getItem('password') ?? '' + setUsername(localStorage.getItem('username') ?? sessionStorage.getItem('username') ?? '') if (!emailRef.current || !passwordRef.current) { messageAPI.error('登陆失效 (2秒后自动跳转至登录页)') setTimeout(() => { @@ -159,8 +161,8 @@ export default function Inbox() { >
-
来自 {email?.from}
-
收件人 {email?.to}
+
来自 {email?.fromName?.length ? `${email?.fromName} <${email?.from}>` : email?.from}
+
收件人 {`${username} <${email?.to}>`}
{new Date(email?.date).toLocaleString()}
@@ -201,7 +203,7 @@ function EmailPreview({ mail, onClick, onDelete }: { mail: Mail, onClick: (_id:
{mail.subject}
{mail.date}
-
来自 {mail.from}
+
来自 {mail.fromName?.length ? `${mail.fromName} <${mail.from}>` : mail.from}
{mail.content}
{ + // 验证邮箱和密码 + const auth = await user.findOne({ email: from, password }) + if (!auth) { + return '401' + } + // 渲染 Markdown + const mail = await marked.parse(content) + const css = await (await fetch('https://cdn.jsdelivr.net/npm/github-markdown-css/github-markdown.css')).text() + const html = ` + + +
+ ${mail} +
+ ` + // 发送邮件 + const { error } = await resend.emails.send({ + from: `${username} <${from}>`, + to: [to], + subject, + html, + }) + if (error) { + return '500a' + } + // 保存邮件 + await sent.insertOne({ + from, + to, + subject, + text: content, + html: ` + + + + + + +
+ ${mail} +
+ + + `, + date: new Date().toISOString() + }).catch(() => { + return '500b' + }) + return true +} diff --git a/app/(dashboard)/send/page.tsx b/app/(dashboard)/send/page.tsx index aec965f..dc1f4bf 100644 --- a/app/(dashboard)/send/page.tsx +++ b/app/(dashboard)/send/page.tsx @@ -1,14 +1,188 @@ 'use client' -import { Button } from 'antd' +import { Button, Input, Form, message, Radio } from 'antd' +import { UserOutlined, CommentOutlined, EditOutlined } from '@ant-design/icons' import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { flushSync } from 'react-dom' +import { sendEmail } from './action' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeRaw from 'rehype-raw' +import 'github-markdown-css/github-markdown.css' + +type FieldType = { + to: string + subject: string + content: string +} export default function Send() { + + // 基础信息 + const [username, setUsername] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + useEffect(() => { + const username = localStorage.getItem('username') ?? sessionStorage.getItem('username') ?? '' + const email = localStorage.getItem('email') ?? sessionStorage.getItem('email') ?? '' + const password = localStorage.getItem('password') ?? sessionStorage.getItem('password') ?? '' + setUsername(username) + setEmail(email) + setPassword(password) + }, []) + + // 内容和预览 + const [content, setContent] = useState('') + const [preview, setPreview] = useState(false) + + // 提交表单 const router = useRouter() + const [form] = Form.useForm() + const [messageAPI, contextHolder] = message.useMessage() + const [disableForm, setDisableForm] = useState(false) + const handleSubmit = (values: FieldType) => { + flushSync(() => setDisableForm(true)) + messageAPI.open({ + content: '发送中...', + key: 'sending', + duration: 0, + type: 'loading' + }) + sendEmail(email, values.to, values.subject, values.content, password, username) + .then(res => { + if (res === '401') { + messageAPI.destroy() + messageAPI.error('登陆失效 (2秒后自动跳转至登录页)') + localStorage.clear() + sessionStorage.clear() + setTimeout(() => { + router.push('/login') + }, 2000) + } else if (res === '500a' || res === '500b') { + messageAPI.destroy() + messageAPI.error('发送失败') + } else { + messageAPI.destroy() + messageAPI.success('发送成功') + form.resetFields() + setContent('') + } + }) + .catch(err => { + messageAPI.destroy() + messageAPI.error(`发送失败: ${err instanceof Error ? err.message : err}`) + }) + .finally(() => { + setDisableForm(false) + }) + } + return ( -
-
开发中...
- +
+ {contextHolder} + + name='send' + form={form} + onFinish={handleSubmit} + className='w-full h-full grid grid-rows-[13.5rem,calc(100%-12.5rem)] md:grid-rows-[5.8rem,calc(100%-5.8rem)] md:gap-[1.1rem]' + disabled={disableForm} + > +
+ + `} + addonBefore={ 发件人} + /> + + ({ + validator(_, value) { + if (value === email) { + return Promise.reject('收件人和发件人不能相同') + } + return Promise.resolve() + } + }) + ]} + > + 收件人} + placeholder='请输入收件人' + /> + + + 主题} + placeholder='请输入主题' + /> + + + + +
+
+ + setPreview(e.target.value === 'preview')} + > + 编辑 + 预览 + + +
+
+ + {content + '

'} +
+
+ + setContent(e.target.value)} + /> + +
+
+
) } \ No newline at end of file diff --git a/app/COLL_TYPE.ts b/app/COLL_TYPE.ts index 6420a47..cc2b648 100644 --- a/app/COLL_TYPE.ts +++ b/app/COLL_TYPE.ts @@ -37,5 +37,11 @@ export type InboxData = { } & Email export type SentData = { - + from: string + to: string + subject: string + text: string + html: string + date: string + attachments?: string[] } \ No newline at end of file diff --git a/app/api/send/email.tsx b/app/api/send/email.tsx deleted file mode 100644 index b627165..0000000 --- a/app/api/send/email.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export function EmailTemplate({ firstName }: Readonly<{ firstName: string }>) { - return ( -
-

Hello {firstName},

-

Welcome to Acme!

-
- ) -} \ No newline at end of file diff --git a/app/api/send/route.ts b/app/api/send/route.ts deleted file mode 100644 index e50e6ab..0000000 --- a/app/api/send/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { EmailTemplate } from './email' -import { Resend } from 'resend' - -const resend = new Resend(process.env.RESEND_API_KEY) - -export async function POST() { - try { - const { data, error } = await resend.emails.send({ - from: '小叶子 ', - to: ['me@leafyee.xyz'], - subject: 'Hello world', - react: EmailTemplate({ firstName: 'John' }), - }) - - if (error) { - return Response.json({ error }, { status: 500 }) - } - return Response.json(data) - - } catch (error) { - return Response.json({ error }, { status: 500 }) - } -} diff --git a/bun.lockb b/bun.lockb index 44f413c..3c23266 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 04a8a3c..1c5bbe9 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,16 @@ "@ant-design/icons": "^5.4.0", "antd": "^5.19.4", "crypto-js": "^4.2.0", + "github-markdown-css": "^5.6.1", + "marked": "^13.0.3", "mongodb": "^6.8.0", "next": "14.2.5", "postal-mime": "^2.2.7", "react": "^18", "react-dom": "^18", + "react-markdown": "^9.0.1", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.0", "resend": "^3.5.0" }, "devDependencies": {