feat: 发送邮件功能 + 邮箱管理 v1.9.0

This commit is contained in:
2026-04-14 12:52:49 +08:00
parent 3eae1595aa
commit e13fa0e337
3 changed files with 495 additions and 0 deletions

View File

@@ -96,6 +96,11 @@ API端点
| `/api/tags` | GET | 列出标签 |
| `/api/tags` | POST | 创建标签 |
| `/api/tags/<id>` | DELETE | 删除标签 |
| `/api/emails` | GET | 列出邮箱 |
| `/api/emails` | POST | 创建邮箱 |
| `/api/emails/<id>` | PUT | 更新邮箱 |
| `/api/emails/<id>` | DELETE | 删除邮箱 |
| `/api/send-email` | POST | 发送收藏到邮箱 |
| `/api/stats` | GET | 统计信息 |
| `/api/search` | GET | 搜索 (参数 q=关键词) |
@@ -136,6 +141,12 @@ xian-favor/
## 版本历史
- v1.9.0 (2026-04-14): 发送邮件功能 + 邮箱管理
- 每个收藏卡片添加"发送邮件"按钮
- 选择已有邮箱或输入新邮箱发送
- 新邮箱自动保存到邮箱管理
- 邮箱管理页面:添加、编辑、删除邮箱
- SMTP配置支持环境变量SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
- v1.8.0 (2026-04-13): AI自动添加功能智能识别文本类型并整理数据
- 首页新增"AI添加"按钮
- 粘贴文本/链接/笔记等AI自动识别类型并提取关键信息

View File

@@ -255,6 +255,157 @@ def search_items():
return jsonify({'success': True, 'data': items})
# ============ 邮箱管理 API ============
@app.route('/api/emails', methods=['GET'])
def list_emails():
"""列出所有邮箱"""
emails = db.list_emails()
return jsonify({'success': True, 'data': emails})
@app.route('/api/emails', methods=['POST'])
def create_email():
"""创建邮箱"""
data = request.get_json()
email_addr = data.get('email', '').strip()
if not email_addr:
return jsonify({'success': False, 'error': '邮箱地址不能为空'}), 400
# 验证邮箱格式
import re
if not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email_addr):
return jsonify({'success': False, 'error': '邮箱格式不正确'}), 400
try:
email_id = db.create_email(email_addr, data.get('name'))
return jsonify({'success': True, 'data': {'id': email_id, 'email': email_addr}}), 201
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/emails/<int:email_id>', methods=['PUT'])
def update_email(email_id):
"""更新邮箱"""
data = request.get_json()
try:
if db.update_email(email_id, email=data.get('email'), name=data.get('name')):
email = db.get_email(email_id)
return jsonify({'success': True, 'data': email})
return jsonify({'success': False, 'error': '邮箱不存在或地址已存在'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/emails/<int:email_id>', methods=['DELETE'])
def delete_email(email_id):
"""删除邮箱"""
if db.delete_email(email_id):
return jsonify({'success': True})
return jsonify({'success': False, 'error': '邮箱不存在'}), 404
@app.route('/api/send-email', methods=['POST'])
def send_email():
"""发送收藏内容到邮箱"""
data = request.get_json()
item_id = data.get('item_id')
email_addr = data.get('email', '').strip()
if not item_id or not email_addr:
return jsonify({'success': False, 'error': '缺少参数'}), 400
# 获取收藏内容
item = db.get_item(item_id)
if not item:
return jsonify({'success': False, 'error': '收藏不存在'}), 404
# 如果是新邮箱,自动保存
import re
if re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email_addr):
db.create_email(email_addr)
# 构建邮件内容
type_labels = {'text': '文本笔记', 'link': '链接收藏', 'column': '专栏订阅', 'todo': '待办事项'}
subject = f"【Xian Favor】{item['title'] or type_labels.get(item['type'], '收藏')}"
body_lines = [
f"类型: {type_labels.get(item['type'], item['type'])}",
f"标题: {item['title'] or '(无标题)'}",
""
]
if item['url']:
body_lines.append(f"链接: {item['url']}")
body_lines.append("")
if item['content']:
body_lines.append("内容:")
body_lines.append(item['content'])
body_lines.append("")
if item['source']:
body_lines.append(f"来源: {item['source']}")
body_lines.append("")
if item['type'] == 'todo':
status_labels = {'pending': '待处理', 'in_progress': '进行中', 'completed': '已完成'}
priority_labels = {'low': '', 'medium': '', 'high': '', 'urgent': '紧急'}
body_lines.append(f"状态: {status_labels.get(item['status'], item['status'])}")
body_lines.append(f"优先级: {priority_labels.get(item['priority'], item['priority'])}")
if item['due_date']:
body_lines.append(f"截止日期: {item['due_date']}")
body_lines.append("")
if item['tags']:
body_lines.append(f"标签: {', '.join(item['tags'])}")
body_lines.append("")
if item['note']:
body_lines.append("详情/备注:")
body_lines.append(item['note'])
body_lines.append("")
body_lines.append(f"创建时间: {item['created_at']}")
body_lines.append("---")
body_lines.append("来自 Xian Favor 收藏系统")
body = "\n".join(body_lines)
# 调用邮件发送技能
try:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
# SMTP配置从环境变量或配置文件获取
smtp_host = os.environ.get('SMTP_HOST', 'smtp.exmail.qq.com')
smtp_port = int(os.environ.get('SMTP_PORT', 465))
smtp_user = os.environ.get('SMTP_USER', 'wlq@tphai.com')
smtp_pass = os.environ.get('SMTP_PASS', '')
if not smtp_pass:
return jsonify({'success': False, 'error': 'SMTP密码未配置请设置环境变量 SMTP_PASS'}), 500
msg = MIMEMultipart()
msg['From'] = smtp_user
msg['To'] = email_addr
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain', 'utf-8'))
with smtplib.SMTP_SSL(smtp_host, smtp_port) as server:
server.login(smtp_user, smtp_pass)
server.sendmail(smtp_user, email_addr, msg.as_string())
return jsonify({'success': True, 'message': f'已发送到 {email_addr}'})
except Exception as e:
return jsonify({'success': False, 'error': f'发送失败: {str(e)}'}), 500
# ============ Web 页面 ============
@app.route('/')
@@ -322,6 +473,7 @@ INDEX_TEMPLATE = '''
<a href="#" data-filter="completed"><i class="bi bi-check-circle"></i> 已完成</a>
<hr class="border-secondary">
<a href="#" onclick="showTagManager(); return false;"><i class="bi bi-tags"></i> 标签管理</a>
<a href="#" onclick="showEmailManager(); return false;"><i class="bi bi-envelope"></i> 邮箱管理</a>
</nav>
</div>
@@ -612,6 +764,74 @@ INDEX_TEMPLATE = '''
</div>
</div>
<!-- 邮箱管理模态框 -->
<div class="modal fade" id="emailManagerModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-envelope"></i> 邮箱管理</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-6">
<input type="text" id="newEmailAddr" class="form-control" placeholder="邮箱地址">
</div>
<div class="col-4">
<input type="text" id="newEmailName" class="form-control" placeholder="备注名称(可选)">
</div>
<div class="col-2">
<button class="btn btn-primary w-100" onclick="createEmail()"><i class="bi bi-plus"></i> 添加</button>
</div>
</div>
<div id="emailListContainer">
<!-- 动态填充 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- 发送邮件模态框 -->
<div class="modal fade" id="sendEmailModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-envelope"></i> 发送到邮箱</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="sendEmailItemId">
<div class="mb-3">
<label class="form-label">选择已有邮箱</label>
<select id="sendEmailSelect" class="form-select">
<option value="">-- 选择邮箱 --</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">或输入新邮箱</label>
<input type="email" id="sendEmailInput" class="form-control" placeholder="输入邮箱地址">
</div>
<div id="sendEmailLoading" style="display:none;">
<div class="text-center py-2">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2">正在发送...</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="sendItemEmail()">
<i class="bi bi-send"></i> 发送
</button>
</div>
</div>
</div>
</div>
<!-- AI自动添加模态框 -->
<div class="modal fade" id="aiAddModal" tabindex="-1">
<div class="modal-dialog modal-lg">
@@ -755,6 +975,7 @@ function renderItems(items) {
</div>
<div class="d-flex align-items-center gap-1 flex-wrap ms-2" onclick="event.stopPropagation();">
${item.tags.slice(0, 2).map(t => `<span class="badge bg-secondary" style="font-size:10px;">${t}</span>`).join('')}
<button class="btn btn-sm btn-outline-info py-0 px-1" onclick="showSendEmailModal(${item.id})" title="发送邮件"><i class="bi bi-envelope" style="font-size:11px;"></i></button>
<button class="btn btn-sm btn-outline-primary py-0 px-1" onclick="openEditModal(${item.id})" title="编辑"><i class="bi bi-pencil" style="font-size:11px;"></i></button>
${item.type === 'todo' && item.status !== 'completed' ? `<button class="btn btn-sm btn-outline-success py-0 px-1" onclick="completeItem(${item.id})" title="完成"><i class="bi bi-check-lg" style="font-size:11px;"></i></button>` : ''}
<button class="btn btn-sm btn-outline-danger py-0 px-1" onclick="deleteItem(${item.id})" title="删除"><i class="bi bi-trash" style="font-size:11px;"></i></button>
@@ -1338,6 +1559,200 @@ async function confirmAIAdd() {
}
}
// ============ 邮箱管理 ============
let allEmails = [];
async function loadEmails() {
const res = await fetch(`${API_BASE}/emails`);
const data = await res.json();
if (data.success) {
allEmails = data.data;
}
}
async function showEmailManager() {
await loadEmailManagerList();
new bootstrap.Modal(document.getElementById('emailManagerModal')).show();
}
async function loadEmailManagerList() {
const res = await fetch(`${API_BASE}/emails`);
const data = await res.json();
if (!data.success) return;
allEmails = data.data;
const container = document.getElementById('emailListContainer');
if (!data.data.length) {
container.innerHTML = '<div class="text-center text-muted py-3">暂无邮箱</div>';
return;
}
container.innerHTML = data.data.map(email => `
<div class="d-flex justify-content-between align-items-center p-2 border-bottom" id="email-row-${email.id}">
<div id="email-display-${email.id}">
<span class="fw-bold">${email.email}</span>
${email.name ? `<span class="text-muted ms-2">(${email.name})</span>` : ''}
</div>
<div id="email-edit-${email.id}" style="display:none;">
<input type="email" class="form-control form-control-sm me-2" id="edit-email-addr-${email.id}" value="${email.email}" style="width:180px;">
<input type="text" class="form-control form-control-sm me-2" id="edit-email-name-${email.id}" value="${email.name || ''}" style="width:120px;">
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" id="email-edit-btn-${email.id}" onclick="showEditEmail(${email.id})" title="编辑">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-success" id="email-save-btn-${email.id}" style="display:none;" onclick="saveEditEmail(${email.id})" title="保存">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-outline-secondary" id="email-cancel-btn-${email.id}" style="display:none;" onclick="cancelEditEmail(${email.id})" title="取消">
<i class="bi bi-x"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteEmailManager(${email.id}, '${email.email}')" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`).join('');
}
async function createEmail() {
const emailAddr = document.getElementById('newEmailAddr').value.trim();
const emailName = document.getElementById('newEmailName').value.trim();
if (!emailAddr) {
alert('请输入邮箱地址');
return;
}
const res = await fetch(`${API_BASE}/emails`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailAddr, name: emailName })
});
const data = await res.json();
if (data.success) {
document.getElementById('newEmailAddr').value = '';
document.getElementById('newEmailName').value = '';
loadEmailManagerList();
} else {
alert(data.error || '添加失败');
}
}
async function deleteEmailManager(id, email) {
if (!confirm(`确认删除邮箱 "${email}"`)) return;
await fetch(`${API_BASE}/emails/${id}`, { method: 'DELETE' });
loadEmailManagerList();
loadEmails();
}
function showEditEmail(id) {
document.getElementById(`email-display-${id}`).style.display = 'none';
document.getElementById(`email-edit-${id}`).style.display = 'flex';
document.getElementById(`email-edit-btn-${id}`).style.display = 'none';
document.getElementById(`email-save-btn-${id}`).style.display = 'inline-block';
document.getElementById(`email-cancel-btn-${id}`).style.display = 'inline-block';
document.getElementById(`edit-email-addr-${id}`).focus();
}
function cancelEditEmail(id) {
const email = allEmails.find(e => e.id === id);
if (email) {
document.getElementById(`edit-email-addr-${id}`).value = email.email;
document.getElementById(`edit-email-name-${id}`).value = email.name || '';
}
document.getElementById(`email-display-${id}`).style.display = 'block';
document.getElementById(`email-edit-${id}`).style.display = 'none';
document.getElementById(`email-edit-btn-${id}`).style.display = 'inline-block';
document.getElementById(`email-save-btn-${id}`).style.display = 'none';
document.getElementById(`email-cancel-btn-${id}`).style.display = 'none';
}
async function saveEditEmail(id) {
const emailAddr = document.getElementById(`edit-email-addr-${id}`).value.trim();
const emailName = document.getElementById(`edit-email-name-${id}`).value.trim();
if (!emailAddr) {
alert('邮箱地址不能为空');
return;
}
const res = await fetch(`${API_BASE}/emails/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailAddr, name: emailName })
});
const data = await res.json();
if (data.success) {
loadEmailManagerList();
loadEmails();
} else {
alert(data.error || '更新失败');
}
}
// ============ 发送邮件 ============
async function showSendEmailModal(itemId) {
document.getElementById('sendEmailItemId').value = itemId;
document.getElementById('sendEmailInput').value = '';
document.getElementById('sendEmailLoading').style.display = 'none';
// 加载已有邮箱
await loadEmails();
const select = document.getElementById('sendEmailSelect');
select.innerHTML = '<option value="">-- 选择邮箱 --</option>' +
allEmails.map(e => `<option value="${e.email}">${e.email}${e.name ? ` (${e.name})` : ''}</option>`).join('');
new bootstrap.Modal(document.getElementById('sendEmailModal')).show();
}
async function sendItemEmail() {
const itemId = document.getElementById('sendEmailItemId').value;
let emailAddr = document.getElementById('sendEmailSelect').value;
// 如果没选择,使用输入的邮箱
if (!emailAddr) {
emailAddr = document.getElementById('sendEmailInput').value.trim();
}
if (!emailAddr) {
alert('请选择或输入邮箱地址');
return;
}
// 显示加载
document.getElementById('sendEmailLoading').style.display = 'block';
try {
const res = await fetch(`${API_BASE}/send-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: parseInt(itemId), email: emailAddr })
});
const data = await res.json();
document.getElementById('sendEmailLoading').style.display = 'none';
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('sendEmailModal')).hide();
alert(data.message || '发送成功!');
// 刷新邮箱列表(新邮箱自动保存)
loadEmails();
} else {
alert(data.error || '发送失败');
}
} catch (e) {
document.getElementById('sendEmailLoading').style.display = 'none';
alert('请求失败: ' + e.message);
}
}
function debounce(fn, delay) {
let timer;
return function(...args) {

View File

@@ -80,6 +80,16 @@ class Database:
)
""")
# 邮箱表
cursor.execute("""
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
created_at TEXT NOT NULL
)
""")
# 创建索引
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_type ON items(type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_status ON items(status)")
@@ -302,6 +312,65 @@ class Database:
""", (item_id,))
return [row['name'] for row in cursor.fetchall()]
# ============ Email 操作 ============
def create_email(self, email: str, name: str = None) -> int:
"""创建邮箱"""
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
try:
cursor.execute("INSERT INTO emails (email, name, created_at) VALUES (?, ?, ?)",
(email, name, now))
conn.commit()
return cursor.lastrowid
except sqlite3.IntegrityError:
# 邮箱已存在返回已有ID
cursor.execute("SELECT id FROM emails WHERE email = ?", (email,))
return cursor.fetchone()['id']
def list_emails(self) -> List[Dict[str, Any]]:
"""列出所有邮箱"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM emails ORDER BY created_at DESC")
return [dict(row) for row in cursor.fetchall()]
def get_email(self, email_id: int) -> Optional[Dict[str, Any]]:
"""获取单个邮箱"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM emails WHERE id = ?", (email_id,))
row = cursor.fetchone()
return dict(row) if row else None
def update_email(self, email_id: int, email: str = None, name: str = None) -> bool:
"""更新邮箱"""
now = datetime.now().isoformat()
with self.get_conn() as conn:
cursor = conn.cursor()
if email:
# 检查邮箱是否已存在(排除自己)
cursor.execute("SELECT id FROM emails WHERE email = ? AND id != ?", (email, email_id))
if cursor.fetchone():
return False # 邎箱已存在
if email and name:
cursor.execute("UPDATE emails SET email = ?, name = ? WHERE id = ?", (email, name, email_id))
elif email:
cursor.execute("UPDATE emails SET email = ? WHERE id = ?", (email, email_id))
elif name:
cursor.execute("UPDATE emails SET name = ? WHERE id = ?", (name, email_id))
conn.commit()
return cursor.rowcount > 0
def delete_email(self, email_id: int) -> bool:
"""删除邮箱"""
with self.get_conn() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM emails WHERE id = ?", (email_id,))
conn.commit()
return cursor.rowcount > 0
def stats(self) -> Dict[str, Any]:
"""获取统计信息"""
self._ensure_init()