Skip to content

Commit

Permalink
发送功能完成
Browse files Browse the repository at this point in the history
  • Loading branch information
LeafYeeXYZ committed Aug 3, 2024
1 parent 649df7a commit 7b4cda1
Show file tree
Hide file tree
Showing 10 changed files with 295 additions and 44 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions app/(dashboard)/inbox/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const inbox = db.collection('inbox')
export type Mail = {
_id: string
from: string
fromName: string
to: string
subject: string
content: string
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
10 changes: 6 additions & 4 deletions app/(dashboard)/inbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default function Inbox() {
const mailsPerPage = 20
const emailRef = useRef<string>('')
const passwordRef = useRef<string>('')
const [username, setUsername] = useState<string>('')
const router = useRouter()
const [messageAPI, contextHolder] = message.useMessage()

Expand Down Expand Up @@ -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<Mail>(loadingEmail)
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(true)
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -159,8 +161,8 @@ export default function Inbox() {
>
<div className='w-dvw h-full absolute left-0 grid grid-rows-[3rem,1fr] sm:grid-rows-[1.75rem,1fr] items-center'>
<div className='w-full h-full flex flex-col sm:flex-row items-start justify-start -mt-8 px-3 gap-1 sm:flex-wrap'>
<div className='w-full sm:w-[49.5%] text-left text-xs text-gray-500'>来自 {email?.from}</div>
<div className='w-full sm:w-[49.5%] sm:text-right text-left text-xs text-gray-500'>收件人 {email?.to}</div>
<div className='w-full sm:w-[49.5%] text-left text-xs text-gray-500'>来自 {email?.fromName?.length ? `${email?.fromName} <${email?.from}>` : email?.from}</div>
<div className='w-full sm:w-[49.5%] sm:text-right text-left text-xs text-gray-500'>收件人 {`${username} <${email?.to}>`}</div>
<div className='w-full text-left text-xs text-gray-500'>{new Date(email?.date).toLocaleString()}</div>
</div>
<div className='w-full h-full border-t'>
Expand Down Expand Up @@ -201,7 +203,7 @@ function EmailPreview({ mail, onClick, onDelete }: { mail: Mail, onClick: (_id:
<div className='text-sm font-bold text-gray-800 overflow-hidden overflow-ellipsis whitespace-nowrap'>{mail.subject}</div>
<div className='text-xs text-gray-500 text-right absolute right-0 top-0'>{mail.date}</div>
</div>
<div className='text-xs my-2 text-gray-500 overflow-hidden overflow-ellipsis whitespace-nowrap'>来自 {mail.from}</div>
<div className='text-xs my-2 text-gray-500 overflow-hidden overflow-ellipsis whitespace-nowrap'>来自 {mail.fromName?.length ? `${mail.fromName} <${mail.from}>` : mail.from}</div>
<div className='text-sm w-[calc(100%-2.8rem)] text-gray-800 overflow-hidden overflow-ellipsis whitespace-nowrap'>{mail.content}</div>
<Popconfirm
title='是否确认删除此邮件'
Expand Down
90 changes: 90 additions & 0 deletions app/(dashboard)/send/action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use server'

import { Resend } from 'resend'
import { MongoClient } from 'mongodb'
import { marked } from 'marked'

// 连接 MongoDB
const client = new MongoClient(process.env.MONGODB_URI!)
const db = client.db('mailbox')
const user = db.collection('user')
const sent = db.collection('sent')
// 连接 Resend
const resend = new Resend(process.env.RESEND_API_KEY)

export async function sendEmail(
from: string,
to: string,
subject: string,
content: string,
password: string,
username: string
): Promise<boolean | string> {
// 验证邮箱和密码
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 = `
<style>
${css}
</style>
<style>
.markdown-body {
min-width: 200px;
max-width: 900px;
padding: 20px;
margin: 0 auto;
}
</style>
<div class='markdown-body'>
${mail}
</div>
`
// 发送邮件
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: `
<html>
<head>
<style>
${css}
</style>
<style>
.markdown-body {
min-width: 200px;
max-width: 900px;
padding: 20px;
margin: 0 auto;
}
</style>
</head>
<body>
<div class='markdown-body'>
${mail}
</div>
</body>
</html>
`,
date: new Date().toISOString()
}).catch(() => {
return '500b'
})
return true
}
182 changes: 178 additions & 4 deletions app/(dashboard)/send/page.tsx
Original file line number Diff line number Diff line change
@@ -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<FieldType>()
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 (
<div className='flex flex-col items-center justify-center h-full w-full'>
<div className="text-center text-lg font-bold my-4">开发中...</div>
<Button type='primary' onClick={() => router.push('/login')}>返回登录</Button>
<div className='flex overflow-hidden flex-col items-center justify-center h-full w-full'>
{contextHolder}
<Form<FieldType>
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}
>
<div className='w-full md:grid md:grid-cols-2 md:grid-rows-2 md:gap-4'>
<Form.Item>
<Input
className='w-full'
disabled
placeholder={`${username} <${email}>`}
addonBefore={<span className='text-gray-400'><UserOutlined /> 发件人</span>}
/>
</Form.Item>
<Form.Item
name='to'
rules={[
{ required: true, message: '请输入收件人' },
{ type: 'email', message: '请输入正确的邮箱地址' },
() => ({
validator(_, value) {
if (value === email) {
return Promise.reject('收件人和发件人不能相同')
}
return Promise.resolve()
}
})
]}
>
<Input
className='w-full'
addonBefore={<span className='text-gray-400'><CommentOutlined /> 收件人</span>}
placeholder='请输入收件人'
/>
</Form.Item>
<Form.Item
name='subject'
rules={[{ required: true, message: '请输入主题' }]}
>
<Input
className='w-full'
addonBefore={<span className='text-gray-400'><EditOutlined /> 主题</span>}
placeholder='请输入主题'
/>
</Form.Item>
<Form.Item>
<Button
htmlType='submit'
className='w-full'
>
发送
</Button>
</Form.Item>
</div>
<div className='w-full h-full grid grid-rows-[2.4rem,calc(100%-3.5rem)]'>
<Form.Item
className='mx-auto'
>
<Radio.Group
defaultValue='edit'
size='small'
buttonStyle='outline'
onChange={e => setPreview(e.target.value === 'preview')}
>
<Radio.Button className='text-xs' value='edit'>编辑</Radio.Button>
<Radio.Button className='text-xs' value='preview'>预览</Radio.Button>
</Radio.Group>
</Form.Item>
<div
style={{ scrollbarWidth: 'none' }}
className='w-full h-full rounded-lg border overflow-x-hidden overflow-y-auto'
>
<div
className='w-full h-full'
style={{ display: preview ? 'block' : 'none' }}
>
<Markdown
className='markdown-body w-full h-full py-2 px-3'
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
>
{content + '<br /><br />'}
</Markdown>
</div>
<Form.Item
name='content'
rules={[{ required: true, message: '请输入邮件内容' }]}
className='w-full h-full'
style={{ display: preview ? 'none' : 'block' }}
>
<Input.TextArea
placeholder='请输入邮件内容 (支持 Markdown 和 HTML)'
autoSize={{ minRows: 5 }}
className='w-full h-full border-none py-2 focus:ring-0'
onChange={e => setContent(e.target.value)}
/>
</Form.Item>
</div>
</div>
</Form>
</div>
)
}
8 changes: 7 additions & 1 deletion app/COLL_TYPE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Loading

0 comments on commit 7b4cda1

Please sign in to comment.