1572 lines
43 KiB
Go
1572 lines
43 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
stdmail "net/mail"
|
|
"strings"
|
|
|
|
"temp_mail/internal/smtpclient"
|
|
"temp_mail/internal/storage"
|
|
)
|
|
|
|
func NewMux(store storage.Store, domain string, smtpClient *smtpclient.Client) http.Handler {
|
|
mux := http.NewServeMux()
|
|
// API
|
|
mux.HandleFunc("/api/address", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
local := r.URL.Query().Get("local")
|
|
local = sanitizeLocal(local)
|
|
created := store.CreateAddress(local)
|
|
writeJSON(w, map[string]interface{}{
|
|
"address": fmt.Sprintf("%s@%s", created, domain),
|
|
"local": created,
|
|
"ttl": int(store.TTL().Minutes()),
|
|
})
|
|
default:
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("/api/messages/", func(w http.ResponseWriter, r *http.Request) {
|
|
// /api/messages/{local} or /api/messages/{local}/{id}
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/messages/")
|
|
parts := strings.Split(path, "/")
|
|
if parts[0] == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
local := sanitizeLocal(parts[0])
|
|
if len(parts) == 1 {
|
|
msgs := store.List(local)
|
|
writeJSON(w, msgs)
|
|
return
|
|
}
|
|
id := parts[1]
|
|
msg, ok := store.Get(local, id)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
switch r.URL.Query().Get("format") {
|
|
case "raw":
|
|
w.Header().Set("Content-Type", "message/rfc822")
|
|
_, _ = w.Write(msg.Raw)
|
|
default:
|
|
writeJSON(w, msg)
|
|
}
|
|
})
|
|
|
|
// 发送邮件API
|
|
mux.HandleFunc("/api/send", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// 解析请求体
|
|
var req struct {
|
|
From string `json:"from"` // 发件人本地部分(如 "test"),将拼接域名
|
|
To []string `json:"to"` // 收件人列表(完整邮箱地址)
|
|
Subject string `json:"subject"` // 邮件主题
|
|
Body string `json:"body"` // 邮件正文
|
|
HTML string `json:"html"` // HTML正文(可选)
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
writeJSON(w, map[string]interface{}{
|
|
"error": "无效的请求格式",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 验证必填字段
|
|
if req.From == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
writeJSON(w, map[string]interface{}{
|
|
"error": "发件人不能为空(请输入您创建的邮箱名称)",
|
|
})
|
|
return
|
|
}
|
|
|
|
if len(req.To) == 0 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
writeJSON(w, map[string]interface{}{
|
|
"error": "收件人不能为空",
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.Subject == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
writeJSON(w, map[string]interface{}{
|
|
"error": "主题不能为空",
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.Body == "" && req.HTML == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
writeJSON(w, map[string]interface{}{
|
|
"error": "邮件内容不能为空",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 验证发件人邮箱是否存在
|
|
fromLocal := sanitizeLocal(req.From)
|
|
if fromLocal == "" {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
writeJSON(w, map[string]interface{}{
|
|
"error": "发件人不能为空",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 如果地址不存在,自动创建
|
|
if !store.AddressExists(fromLocal) {
|
|
store.CreateAddress(fromLocal)
|
|
log.Printf("自动创建发件邮箱: %s@%s", fromLocal, domain)
|
|
}
|
|
|
|
// 构造完整的发件人地址
|
|
fromAddr := fmt.Sprintf("%s@%s", fromLocal, domain)
|
|
|
|
// 发送邮件
|
|
msg := smtpclient.Message{
|
|
From: fromAddr,
|
|
To: req.To,
|
|
Subject: req.Subject,
|
|
Body: req.Body,
|
|
HTML: req.HTML,
|
|
}
|
|
|
|
if err := smtpClient.Send(msg); err != nil {
|
|
log.Printf("发送邮件失败 (from=%s): %v", fromAddr, err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
writeJSON(w, map[string]interface{}{
|
|
"error": fmt.Sprintf("发送失败: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
log.Printf("邮件已发送: from=%s, to=%v, subject=%s", fromAddr, req.To, req.Subject)
|
|
writeJSON(w, map[string]interface{}{
|
|
"success": true,
|
|
"message": "邮件已发送",
|
|
"from": fromAddr,
|
|
})
|
|
})
|
|
|
|
// UI
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(indexHTML))
|
|
})
|
|
|
|
// Message detail page
|
|
mux.HandleFunc("/view/", func(w http.ResponseWriter, r *http.Request) {
|
|
// /view/{local}/{id}
|
|
path := strings.TrimPrefix(r.URL.Path, "/view/")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
local := sanitizeLocal(parts[0])
|
|
id := parts[1]
|
|
|
|
msg, ok := store.Get(local, id)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(renderMessageDetailPage(msg, local, domain)))
|
|
})
|
|
|
|
return mux
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
log.Printf("writeJSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func sanitizeLocal(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
s = strings.Trim(s, "@ ")
|
|
if i := strings.IndexByte(s, '@'); i > 0 {
|
|
s = s[:i]
|
|
}
|
|
if i := strings.IndexByte(s, '+'); i > 0 {
|
|
s = s[:i]
|
|
}
|
|
if s == "" {
|
|
return s
|
|
}
|
|
// allow [a-z0-9-.]
|
|
b := strings.Builder{}
|
|
for _, ch := range s {
|
|
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.' || ch == '_' {
|
|
b.WriteRune(ch)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func renderMessageDetailPage(msg storage.Message, local, domain string) string {
|
|
// Parse email content from raw bytes
|
|
htmlContent, textContent, _ := parseEmailContent(msg.Raw)
|
|
|
|
// Choose which content to display
|
|
bodyHTML := ""
|
|
if htmlContent != "" {
|
|
bodyHTML = fmt.Sprintf(`<iframe id="html-frame" srcdoc="%s" sandbox="allow-same-origin"></iframe>`, escapeHTMLAttr(htmlContent))
|
|
} else if textContent != "" {
|
|
bodyHTML = fmt.Sprintf(`<pre class="text-content">%s</pre>`, escapeHTML(textContent))
|
|
} else {
|
|
bodyHTML = `<div class="no-content">无邮件正文</div>`
|
|
}
|
|
|
|
timeStr := msg.CreatedAt.Format("2006-01-02 15:04:05")
|
|
|
|
return fmt.Sprintf(messageDetailTemplate,
|
|
escapeHTML(msg.Subject),
|
|
local,
|
|
escapeHTML(msg.Subject),
|
|
escapeHTML(msg.From),
|
|
timeStr,
|
|
bodyHTML,
|
|
local,
|
|
local,
|
|
msg.ID,
|
|
)
|
|
}
|
|
|
|
func parseEmailContent(raw []byte) (htmlContent, textContent string, headers map[string]string) {
|
|
headers = make(map[string]string)
|
|
|
|
if len(raw) == 0 {
|
|
return "", "", headers
|
|
}
|
|
|
|
// Parse email using net/mail
|
|
m, err := stdmail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
textContent = string(raw)
|
|
return "", textContent, headers
|
|
}
|
|
|
|
// Extract and decode headers using mime.WordDecoder
|
|
dec := new(mime.WordDecoder)
|
|
for k := range m.Header {
|
|
rawValue := m.Header.Get(k)
|
|
// Decode MIME encoded-words (=?charset?encoding?text?=)
|
|
if decoded, err := dec.DecodeHeader(rawValue); err == nil {
|
|
headers[k] = decoded
|
|
} else {
|
|
headers[k] = rawValue
|
|
}
|
|
}
|
|
|
|
// Read body
|
|
body, err := io.ReadAll(m.Body)
|
|
if err != nil {
|
|
return "", "", headers
|
|
}
|
|
|
|
// Check Content-Transfer-Encoding and decode body
|
|
contentTransferEncoding := strings.ToLower(m.Header.Get("Content-Transfer-Encoding"))
|
|
decodedBody := body
|
|
|
|
switch contentTransferEncoding {
|
|
case "base64":
|
|
if decoded, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
|
|
decodedBody = decoded
|
|
}
|
|
case "quoted-printable":
|
|
// Quoted-printable decoding
|
|
decodedBody = decodeQuotedPrintable(body)
|
|
}
|
|
|
|
bodyStr := string(decodedBody)
|
|
|
|
// Check if it's multipart
|
|
contentType := m.Header.Get("Content-Type")
|
|
if strings.Contains(contentType, "multipart") {
|
|
// Simple multipart parsing with encoding support
|
|
htmlContent, textContent = parseMultipart(bodyStr, contentType)
|
|
} else if strings.Contains(contentType, "text/html") {
|
|
htmlContent = bodyStr
|
|
} else {
|
|
textContent = bodyStr
|
|
}
|
|
|
|
return htmlContent, textContent, headers
|
|
}
|
|
|
|
func parseMultipart(body, contentType string) (html, text string) {
|
|
// Extract boundary
|
|
boundary := ""
|
|
if idx := strings.Index(contentType, "boundary="); idx >= 0 {
|
|
boundary = contentType[idx+9:]
|
|
boundary = strings.Trim(boundary, `"`)
|
|
if idx2 := strings.IndexAny(boundary, "; \t\r\n"); idx2 >= 0 {
|
|
boundary = boundary[:idx2]
|
|
}
|
|
}
|
|
|
|
if boundary == "" {
|
|
return "", body
|
|
}
|
|
|
|
parts := strings.Split(body, "--"+boundary)
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" || part == "--" {
|
|
continue
|
|
}
|
|
|
|
// Split headers and body
|
|
splitIdx := strings.Index(part, "\r\n\r\n")
|
|
if splitIdx < 0 {
|
|
splitIdx = strings.Index(part, "\n\n")
|
|
if splitIdx < 0 {
|
|
continue
|
|
}
|
|
splitIdx += 2
|
|
} else {
|
|
splitIdx += 4
|
|
}
|
|
|
|
headers := part[:splitIdx]
|
|
content := part[splitIdx:]
|
|
|
|
// Decode content based on Content-Transfer-Encoding
|
|
if strings.Contains(headers, "base64") {
|
|
content = strings.ReplaceAll(content, "\r\n", "")
|
|
content = strings.ReplaceAll(content, "\n", "")
|
|
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
|
|
content = string(decoded)
|
|
}
|
|
} else if strings.Contains(headers, "quoted-printable") {
|
|
content = string(decodeQuotedPrintable([]byte(content)))
|
|
}
|
|
|
|
if strings.Contains(headers, "text/html") {
|
|
html = content
|
|
} else if strings.Contains(headers, "text/plain") && text == "" {
|
|
text = content
|
|
}
|
|
}
|
|
|
|
return html, text
|
|
}
|
|
|
|
// decodeQuotedPrintable decodes quoted-printable encoded text
|
|
func decodeQuotedPrintable(data []byte) []byte {
|
|
var result bytes.Buffer
|
|
i := 0
|
|
for i < len(data) {
|
|
if data[i] == '=' {
|
|
if i+2 < len(data) {
|
|
// Soft line break
|
|
if data[i+1] == '\r' && data[i+2] == '\n' {
|
|
i += 3
|
|
continue
|
|
}
|
|
if data[i+1] == '\n' {
|
|
i += 2
|
|
continue
|
|
}
|
|
// Hex encoding
|
|
if i+2 < len(data) {
|
|
hex := string(data[i+1 : i+3])
|
|
var b byte
|
|
if _, err := fmt.Sscanf(hex, "%02X", &b); err == nil {
|
|
result.WriteByte(b)
|
|
i += 3
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
// Invalid encoding, keep the =
|
|
result.WriteByte(data[i])
|
|
i++
|
|
} else {
|
|
result.WriteByte(data[i])
|
|
i++
|
|
}
|
|
}
|
|
return result.Bytes()
|
|
}
|
|
|
|
func escapeHTML(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, "\"", """)
|
|
s = strings.ReplaceAll(s, "'", "'")
|
|
return s
|
|
}
|
|
|
|
func escapeHTMLAttr(s string) string {
|
|
s = escapeHTML(s)
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
s = strings.ReplaceAll(s, "\r", " ")
|
|
return s
|
|
}
|
|
|
|
const indexHTML = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>临时邮箱 - Temp Mail</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #e0e7ff 0%%, #f3f4f6 100%%);
|
|
min-height: 100vh;
|
|
padding: 1rem 1rem;
|
|
color: #2d3748;
|
|
}
|
|
|
|
.container {
|
|
max-width: 700px;
|
|
margin: 0 auto;
|
|
animation: fadeIn 0.6s ease-out;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 1rem;
|
|
color: #1a202c;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
margin-bottom: 0.2rem;
|
|
color: #1a202c;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.header p {
|
|
font-size: 0.8rem;
|
|
opacity: 0.7;
|
|
font-weight: 400;
|
|
color: #4a5568;
|
|
}
|
|
|
|
.card {
|
|
background: rgba(255, 255, 255, 0.85);
|
|
backdrop-filter: blur(20px) saturate(180%);
|
|
border-radius: 16px;
|
|
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.15),
|
|
0 1px 3px rgba(255, 255, 255, 0.5) inset;
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
padding: 1rem;
|
|
margin-bottom: 1rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.card:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 12px 40px rgba(31, 38, 135, 0.2),
|
|
0 2px 4px rgba(255, 255, 255, 0.6) inset;
|
|
}
|
|
|
|
.create-section {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: stretch;
|
|
margin-bottom: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.input-wrapper {
|
|
flex: 1;
|
|
min-width: 180px;
|
|
position: relative;
|
|
}
|
|
|
|
.input-wrapper input {
|
|
width: 100%;
|
|
padding: 0.6rem 0.85rem;
|
|
font-size: 0.85rem;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
transition: all 0.3s ease;
|
|
font-family: inherit;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.input-wrapper input:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
background: white;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.6rem 1.2rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
font-family: inherit;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
|
|
}
|
|
|
|
.btn-primary:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.address-display {
|
|
background: linear-gradient(135deg, #f6f8fb 0%, #e9ecef 100%);
|
|
padding: 0.75rem;
|
|
border-radius: 8px;
|
|
border: 2px dashed #cbd5e0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.address-label {
|
|
font-size: 0.65rem;
|
|
color: #718096;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
|
|
.address-value {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
color: #2d3748;
|
|
font-family: 'Courier New', monospace;
|
|
background: white;
|
|
padding: 0.35rem 0.65rem;
|
|
border-radius: 6px;
|
|
border: 1px solid #e2e8f0;
|
|
flex: 1;
|
|
min-width: 200px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.address-value.empty {
|
|
color: #a0aec0;
|
|
font-style: italic;
|
|
}
|
|
|
|
.btn-copy {
|
|
background: #48bb78;
|
|
color: white;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
border: none;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.btn-copy:hover {
|
|
background: #38a169;
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.messages-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
flex-wrap: wrap;
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
.messages-header h2 {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
color: #2d3748;
|
|
}
|
|
|
|
.badge {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 0.25rem 0.6rem;
|
|
border-radius: 20px;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1.5rem;
|
|
border-bottom: 2px solid #e2e8f0;
|
|
}
|
|
|
|
.tab {
|
|
padding: 0.75rem 1.5rem;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 3px solid transparent;
|
|
color: #718096;
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
position: relative;
|
|
bottom: -2px;
|
|
}
|
|
|
|
.tab:hover {
|
|
color: #667eea;
|
|
background: rgba(102, 126, 234, 0.05);
|
|
}
|
|
|
|
.tab.active {
|
|
color: #667eea;
|
|
border-bottom-color: #667eea;
|
|
}
|
|
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
animation: fadeIn 0.3s ease;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.messages-container {
|
|
background: #f8fafc;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.message-item {
|
|
background: white;
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
transition: all 0.3s ease;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.message-item.new-item {
|
|
animation: slideIn 0.4s ease-out;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from { opacity: 0; transform: translateX(-20px); }
|
|
to { opacity: 1; transform: translateX(0); }
|
|
}
|
|
|
|
.message-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.message-item:hover {
|
|
background: #f7fafc;
|
|
transform: translateX(4px);
|
|
}
|
|
|
|
.message-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 0.4rem;
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
.message-from {
|
|
font-weight: 600;
|
|
color: #2d3748;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.message-time {
|
|
font-size: 0.7rem;
|
|
color: #718096;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.message-subject {
|
|
font-weight: 600;
|
|
color: #4a5568;
|
|
margin-bottom: 0.3rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.message-snippet {
|
|
color: #718096;
|
|
font-size: 0.75rem;
|
|
line-height: 1.4;
|
|
margin-bottom: 0.5rem;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.message-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.btn-small {
|
|
padding: 0.35rem 0.75rem;
|
|
font-size: 0.8rem;
|
|
border-radius: 5px;
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.btn-outline {
|
|
border: 1px solid #667eea;
|
|
color: #667eea;
|
|
background: white;
|
|
}
|
|
|
|
.btn-outline:hover {
|
|
background: #667eea;
|
|
color: white;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 2.5rem 1.5rem;
|
|
color: #718096;
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 0.75rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.empty-state h3 {
|
|
font-size: 1.1rem;
|
|
margin-bottom: 0.4rem;
|
|
color: #4a5568;
|
|
}
|
|
|
|
.empty-state p {
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.loading {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid #e2e8f0;
|
|
border-top-color: #667eea;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
right: 2rem;
|
|
background: white;
|
|
padding: 1rem 1.5rem;
|
|
border-radius: 12px;
|
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
transform: translateY(100px);
|
|
opacity: 0;
|
|
transition: all 0.3s ease;
|
|
z-index: 1000;
|
|
border-left: 4px solid #48bb78;
|
|
}
|
|
|
|
.toast.show {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.header h1 {
|
|
font-size: 2rem;
|
|
}
|
|
|
|
.card {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.create-section {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.btn {
|
|
width: 100%;
|
|
}
|
|
|
|
.address-display {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.message-header {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|
|
<script>
|
|
let currentLocal = '';
|
|
let pollInterval = null;
|
|
let messageTTL = 30;
|
|
let lastMessageIds = [];
|
|
|
|
async function createAddr() {
|
|
const desired = document.getElementById('local').value.trim();
|
|
const btn = event.target;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading"></span>';
|
|
|
|
try {
|
|
const r = await fetch('/api/address?local=' + encodeURIComponent(desired), {method: 'POST'});
|
|
const j = await r.json();
|
|
currentLocal = j.local;
|
|
messageTTL = j.ttl || 30;
|
|
|
|
const addrEl = document.getElementById('addr');
|
|
addrEl.textContent = j.address;
|
|
addrEl.classList.remove('empty');
|
|
|
|
document.getElementById('copy-section').style.display = 'flex';
|
|
document.getElementById('ttl-info').style.display = 'block';
|
|
document.getElementById('ttl-minutes').textContent = messageTTL;
|
|
|
|
// 设置发件人地址
|
|
document.getElementById('send-from').value = j.address;
|
|
|
|
loadMsgs();
|
|
startPolling();
|
|
showToast('邮箱地址已创建!邮件保留 ' + messageTTL + ' 分钟');
|
|
} catch (e) {
|
|
showToast('创建失败,请重试', 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '创建邮箱';
|
|
}
|
|
}
|
|
|
|
async function loadMsgs() {
|
|
if (!currentLocal) return;
|
|
|
|
try {
|
|
const r = await fetch('/api/messages/' + currentLocal);
|
|
const msgs = await r.json() || [];
|
|
|
|
const container = document.getElementById('messages-container');
|
|
const badge = document.getElementById('inbox-badge');
|
|
badge.textContent = msgs.length;
|
|
|
|
if (msgs.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><h3>收件箱为空</h3><p>等待新邮件到达...</p></div>';
|
|
lastMessageIds = [];
|
|
return;
|
|
}
|
|
|
|
// 检查邮件列表是否有变化(ID 和数量)
|
|
const currentIds = msgs.map(m => m.id);
|
|
const hasChanged = currentIds.length !== lastMessageIds.length ||
|
|
currentIds.some((id, i) => id !== lastMessageIds[i]);
|
|
|
|
if (hasChanged) {
|
|
// 只有在邮件列表真正变化时才完全重新渲染
|
|
renderMessages(msgs);
|
|
lastMessageIds = currentIds;
|
|
} else {
|
|
// 如果邮件列表没变,只更新倒计时
|
|
updateTimers(msgs);
|
|
}
|
|
} catch (e) {
|
|
console.error('加载消息失败:', e);
|
|
}
|
|
}
|
|
|
|
function renderMessages(msgs) {
|
|
const container = document.getElementById('messages-container');
|
|
const existingIds = Array.from(container.querySelectorAll('.message-item')).map(el => el.getAttribute('data-msg-id'));
|
|
container.innerHTML = '';
|
|
|
|
for (const m of msgs) {
|
|
const div = document.createElement('div');
|
|
const isNew = !existingIds.includes(m.id);
|
|
div.className = isNew ? 'message-item new-item' : 'message-item';
|
|
div.setAttribute('data-msg-id', m.id);
|
|
|
|
const time = new Date(m.createdAt);
|
|
const expiresAt = new Date(m.expiresAt);
|
|
const now = new Date();
|
|
const minutesLeft = Math.max(0, Math.floor((expiresAt - now) / 60000));
|
|
|
|
const timeStr = time.toLocaleString('zh-CN', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
let expiryBadge = '';
|
|
if (minutesLeft > 0) {
|
|
const expiryColor = minutesLeft <= 5 ? '#e53e3e' : (minutesLeft <= 15 ? '#dd6b20' : '#48bb78');
|
|
expiryBadge = '<span class="expiry-timer" style="font-size:0.8rem;color:' + expiryColor + ';margin-left:0.5rem;" data-expires="' + m.expiresAt + '">⏱ ' + minutesLeft + '分钟后过期</span>';
|
|
}
|
|
|
|
div.innerHTML =
|
|
'<div class="message-header">' +
|
|
'<div class="message-from">' + escapeHtml(m.from || '未知发件人') + '</div>' +
|
|
'<div class="message-time">' + timeStr + expiryBadge + '</div>' +
|
|
'</div>' +
|
|
'<div class="message-subject">' + escapeHtml(m.subject || '(无主题)') + '</div>' +
|
|
'<div class="message-snippet">' + escapeHtml(m.snippet || '') + '</div>' +
|
|
'<div class="message-actions">' +
|
|
'<a href="/view/' + currentLocal + '/' + m.id + '" class="btn-small btn-outline">查看详情</a>' +
|
|
'<a href="/api/messages/' + currentLocal + '/' + m.id + '?format=raw" download="message.eml" class="btn-small btn-outline" style="margin-left:0.5rem;">下载 EML</a>' +
|
|
'</div>';
|
|
|
|
container.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function updateTimers(msgs) {
|
|
// 只更新倒计时,不重新渲染整个列表
|
|
const now = new Date();
|
|
|
|
msgs.forEach(m => {
|
|
const msgEl = document.querySelector('[data-msg-id="' + m.id + '"]');
|
|
if (!msgEl) return;
|
|
|
|
const timerEl = msgEl.querySelector('.expiry-timer');
|
|
if (!timerEl) return;
|
|
|
|
const expiresAt = new Date(m.expiresAt);
|
|
const minutesLeft = Math.max(0, Math.floor((expiresAt - now) / 60000));
|
|
|
|
if (minutesLeft > 0) {
|
|
const expiryColor = minutesLeft <= 5 ? '#e53e3e' : (minutesLeft <= 15 ? '#dd6b20' : '#48bb78');
|
|
timerEl.style.color = expiryColor;
|
|
timerEl.textContent = '⏱ ' + minutesLeft + '分钟后过期';
|
|
}
|
|
});
|
|
}
|
|
|
|
function copyAddress() {
|
|
const addr = document.getElementById('addr').textContent;
|
|
if (addr === '(尚未创建)') return;
|
|
|
|
// 尝试使用现代 Clipboard API
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(addr).then(() => {
|
|
showToast('地址已复制到剪贴板!');
|
|
}).catch((err) => {
|
|
console.error('Clipboard API failed:', err);
|
|
fallbackCopy(addr);
|
|
});
|
|
} else {
|
|
// 降级到传统方法
|
|
fallbackCopy(addr);
|
|
}
|
|
}
|
|
|
|
function fallbackCopy(text) {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
|
|
try {
|
|
const successful = document.execCommand('copy');
|
|
if (successful) {
|
|
showToast('地址已复制到剪贴板!');
|
|
} else {
|
|
showToast('复制失败,请手动复制', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('Fallback copy failed:', err);
|
|
showToast('复制失败,请手动复制', 'error');
|
|
} finally {
|
|
document.body.removeChild(textarea);
|
|
}
|
|
}
|
|
|
|
function switchTab(tabName) {
|
|
// 更新选项卡按钮状态
|
|
const tabs = document.querySelectorAll('.tab');
|
|
tabs.forEach(tab => tab.classList.remove('active'));
|
|
event.target.classList.add('active');
|
|
|
|
// 更新选项卡内容显示
|
|
document.getElementById('inbox-tab').classList.remove('active');
|
|
document.getElementById('compose-tab').classList.remove('active');
|
|
|
|
if (tabName === 'inbox') {
|
|
document.getElementById('inbox-tab').classList.add('active');
|
|
} else if (tabName === 'compose') {
|
|
document.getElementById('compose-tab').classList.add('active');
|
|
|
|
// 切换到发送邮件时,检查是否已创建邮箱
|
|
if (!currentLocal) {
|
|
showToast('请先创建邮箱后再发送邮件', 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearCompose() {
|
|
// 不清空发件人(自动填充的)
|
|
document.getElementById('send-to').value = '';
|
|
document.getElementById('send-subject').value = '';
|
|
document.getElementById('send-body').value = '';
|
|
document.getElementById('send-status').style.display = 'none';
|
|
}
|
|
|
|
async function sendEmail() {
|
|
// 检查是否已创建邮箱
|
|
if (!currentLocal) {
|
|
showToast('请先创建邮箱后再发送邮件', 'error');
|
|
return;
|
|
}
|
|
|
|
const to = document.getElementById('send-to').value.trim();
|
|
const subject = document.getElementById('send-subject').value.trim();
|
|
const body = document.getElementById('send-body').value.trim();
|
|
|
|
// 验证必填字段
|
|
if (!to) {
|
|
showToast('请输入收件人', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!subject) {
|
|
showToast('请输入邮件主题', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!body) {
|
|
showToast('请输入邮件内容', 'error');
|
|
return;
|
|
}
|
|
|
|
// 解析收件人(支持逗号分隔的多个邮箱)
|
|
const toList = to.split(',').map(e => e.trim()).filter(e => e);
|
|
|
|
const btn = document.getElementById('send-btn-text');
|
|
const originalText = btn.textContent;
|
|
btn.textContent = '📤 发送中...';
|
|
|
|
const statusEl = document.getElementById('send-status');
|
|
statusEl.style.display = 'none';
|
|
|
|
try {
|
|
const response = await fetch('/api/send', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
from: currentLocal, // 使用当前邮箱的本地部分
|
|
to: toList,
|
|
subject: subject,
|
|
body: body,
|
|
}),
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
statusEl.style.display = 'block';
|
|
statusEl.style.background = '#c6f6d5';
|
|
statusEl.style.borderLeft = '4px solid #48bb78';
|
|
statusEl.style.color = '#22543d';
|
|
statusEl.innerHTML = '<strong>✅ 发送成功!</strong><br>' + result.message;
|
|
|
|
showToast('邮件已发送!');
|
|
|
|
// 清空表单(不关闭)
|
|
setTimeout(() => {
|
|
clearCompose();
|
|
}, 2000);
|
|
} else {
|
|
throw new Error(result.error || '发送失败');
|
|
}
|
|
} catch (error) {
|
|
statusEl.style.display = 'block';
|
|
statusEl.style.background = '#fed7d7';
|
|
statusEl.style.borderLeft = '4px solid #e53e3e';
|
|
statusEl.style.color = '#742a2a';
|
|
statusEl.innerHTML = '<strong>❌ 发送失败</strong><br>' + error.message;
|
|
|
|
showToast('发送失败: ' + error.message, 'error');
|
|
} finally {
|
|
btn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
if (pollInterval) clearInterval(pollInterval);
|
|
pollInterval = setInterval(loadMsgs, 4000);
|
|
}
|
|
|
|
function showToast(message, type = 'success') {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = 'toast show';
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.getElementById('local').addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') createAddr();
|
|
});
|
|
|
|
// 从 URL 参数恢复邮箱地址
|
|
const params = new URLSearchParams(window.location.search);
|
|
const mailbox = params.get('mailbox');
|
|
if (mailbox) {
|
|
currentLocal = mailbox;
|
|
document.getElementById('local').value = mailbox;
|
|
|
|
// 模拟创建邮箱(不调用API,直接设置界面)
|
|
fetch('/api/address?local=' + encodeURIComponent(mailbox), {method: 'POST'})
|
|
.then(r => r.json())
|
|
.then(j => {
|
|
messageTTL = j.ttl || 30;
|
|
const addrEl = document.getElementById('addr');
|
|
addrEl.textContent = j.address;
|
|
addrEl.classList.remove('empty');
|
|
document.getElementById('copy-section').style.display = 'flex';
|
|
document.getElementById('ttl-info').style.display = 'block';
|
|
document.getElementById('ttl-minutes').textContent = messageTTL;
|
|
document.getElementById('send-from').value = j.address;
|
|
loadMsgs();
|
|
startPolling();
|
|
})
|
|
.catch(e => console.error('恢复邮箱失败:', e));
|
|
}
|
|
});
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>📬 临时邮箱</h1>
|
|
<p>快速创建临时邮箱,接收验证邮件</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="create-section">
|
|
<div class="input-wrapper">
|
|
<input id="local" type="text" placeholder="自定义邮箱名称(可选)" autocomplete="off" />
|
|
</div>
|
|
<button class="btn btn-primary" onclick="createAddr()">创建邮箱</button>
|
|
</div>
|
|
|
|
<div class="address-display">
|
|
<div>
|
|
<div class="address-label">当前邮箱地址</div>
|
|
<div id="addr" class="address-value empty">(尚未创建)</div>
|
|
</div>
|
|
<button id="copy-section" class="btn-copy" onclick="copyAddress()" style="display:none;">复制地址</button>
|
|
</div>
|
|
|
|
<div id="ttl-info" style="display:none;margin-top:1rem;padding:1rem;background:#fff3cd;border-left:4px solid #ffc107;border-radius:8px;">
|
|
<div style="display:flex;align-items:center;gap:0.5rem;color:#856404;">
|
|
<span style="font-size:1.2rem;">⏰</span>
|
|
<span style="font-weight:600;">重要提示:</span>
|
|
<span>邮件将在收到后 <strong id="ttl-minutes">30</strong> 分钟后自动删除</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<!-- 选项卡导航 -->
|
|
<div class="tabs">
|
|
<button class="tab active" onclick="switchTab('inbox')">
|
|
📨 收件箱 <span id="inbox-badge" class="badge" style="margin-left:0.5rem;">0</span>
|
|
</button>
|
|
<button class="tab" onclick="switchTab('compose')">
|
|
📤 发送邮件
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 收件箱选项卡 -->
|
|
<div id="inbox-tab" class="tab-content active">
|
|
<div id="messages-container" class="messages-container">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">✨</div>
|
|
<h3>开始使用</h3>
|
|
<p>创建邮箱地址后,邮件将自动出现在这里</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 发送邮件选项卡 -->
|
|
<div id="compose-tab" class="tab-content">
|
|
<div style="display:flex;flex-direction:column;gap:1rem;">
|
|
<div>
|
|
<label style="display:block;margin-bottom:0.5rem;font-weight:600;color:#4a5568;">发件人</label>
|
|
<input id="send-from" type="text" readonly
|
|
style="width:100%;padding:0.75rem;border:2px solid #e2e8f0;border-radius:8px;font-size:1rem;background:#f7fafc;color:#4a5568;cursor:not-allowed;"
|
|
placeholder="请先创建邮箱" />
|
|
<small style="color:#718096;margin-top:0.25rem;display:block;">使用当前创建的邮箱地址</small>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="display:block;margin-bottom:0.5rem;font-weight:600;color:#4a5568;">收件人 *</label>
|
|
<input id="send-to" type="email" placeholder="recipient@example.com" required
|
|
style="width:100%;padding:0.75rem;border:2px solid #e2e8f0;border-radius:8px;font-size:1rem;transition:all 0.2s;"
|
|
onfocus="this.style.borderColor='#667eea'"
|
|
onblur="this.style.borderColor='#e2e8f0'" />
|
|
<small style="color:#718096;margin-top:0.25rem;display:block;">多个收件人用逗号分隔</small>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="display:block;margin-bottom:0.5rem;font-weight:600;color:#4a5568;">主题 *</label>
|
|
<input id="send-subject" type="text" placeholder="邮件主题" required
|
|
style="width:100%;padding:0.75rem;border:2px solid #e2e8f0;border-radius:8px;font-size:1rem;transition:all 0.2s;"
|
|
onfocus="this.style.borderColor='#667eea'"
|
|
onblur="this.style.borderColor='#e2e8f0'" />
|
|
</div>
|
|
|
|
<div>
|
|
<label style="display:block;margin-bottom:0.5rem;font-weight:600;color:#4a5568;">内容 *</label>
|
|
<textarea id="send-body" placeholder="邮件正文..." required
|
|
style="width:100%;padding:0.75rem;border:2px solid #e2e8f0;border-radius:8px;font-size:1rem;min-height:150px;resize:vertical;font-family:inherit;transition:all 0.2s;"
|
|
onfocus="this.style.borderColor='#667eea'"
|
|
onblur="this.style.borderColor='#e2e8f0'"></textarea>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:1rem;">
|
|
<button class="btn btn-primary" onclick="sendEmail()" style="flex:1;">
|
|
<span id="send-btn-text">📤 发送</span>
|
|
</button>
|
|
<button class="btn" onclick="clearCompose()" style="flex:1;background:#f7fafc;color:#4a5568;">
|
|
🗑️ 清空
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="send-status" style="display:none;margin-top:1rem;padding:1rem;border-radius:8px;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast" class="toast"></div>
|
|
</body>
|
|
</html>`
|
|
|
|
const messageDetailTemplate = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>%s - 邮件详情</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #e0e7ff 0%%, #f3f4f6 100%%);
|
|
min-height: 100vh;
|
|
padding: 1rem 1rem;
|
|
color: #2d3748;
|
|
}
|
|
|
|
.container {
|
|
max-width: 650px;
|
|
margin: 0 auto;
|
|
animation: fadeIn 0.6s ease-out;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.back-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
color: #4a5568;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
margin-bottom: 0.75rem;
|
|
padding: 0.4rem 0.85rem;
|
|
background: rgba(255, 255, 255, 0.7);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
border-radius: 8px;
|
|
transition: all 0.3s ease;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.back-btn:hover {
|
|
background: rgba(255, 255, 255, 0.9);
|
|
transform: translateX(-3px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.card {
|
|
background: rgba(255, 255, 255, 0.85);
|
|
backdrop-filter: blur(20px) saturate(180%%);
|
|
border-radius: 16px;
|
|
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.15),
|
|
0 1px 3px rgba(255, 255, 255, 0.5) inset;
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.email-header {
|
|
background: linear-gradient(135deg, #f6f8fb 0%%, #e9ecef 100%%);
|
|
padding: 1rem;
|
|
border-bottom: 2px solid #e2e8f0;
|
|
}
|
|
|
|
.subject {
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
color: #2d3748;
|
|
margin-bottom: 0.75rem;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.meta-row {
|
|
display: grid;
|
|
grid-template-columns: 70px 1fr;
|
|
gap: 0.6rem;
|
|
margin-bottom: 0.4rem;
|
|
align-items: start;
|
|
}
|
|
|
|
.meta-label {
|
|
font-weight: 600;
|
|
color: #718096;
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.meta-value {
|
|
color: #2d3748;
|
|
word-break: break-all;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.email-body {
|
|
padding: 1rem;
|
|
min-height: 300px;
|
|
background: white;
|
|
}
|
|
|
|
#html-frame {
|
|
width: 100%%;
|
|
min-height: 350px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
background: white;
|
|
}
|
|
|
|
.text-content {
|
|
font-family: 'Courier New', monospace;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
color: #2d3748;
|
|
line-height: 1.5;
|
|
background: #f8fafc;
|
|
padding: 0.75rem;
|
|
border-radius: 6px;
|
|
border: 1px solid #e2e8f0;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.no-content {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: #a0aec0;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.action-bar {
|
|
display: flex;
|
|
gap: 0.6rem;
|
|
padding: 0.75rem 1rem;
|
|
background: #f8fafc;
|
|
border-top: 2px solid #e2e8f0;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.5rem 1rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
font-family: inherit;
|
|
text-decoration: none;
|
|
display: inline-block;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #f093fb 0%%, #f5576c 100%%);
|
|
color: white;
|
|
box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 20px rgba(245, 87, 108, 0.5);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: white;
|
|
color: #667eea;
|
|
border: 2px solid #667eea;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #667eea;
|
|
color: white;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.meta-row {
|
|
grid-template-columns: 1fr;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.tabs {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.tab {
|
|
padding: 0.75rem 1rem;
|
|
font-size: 0.9rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tab-content {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.action-bar {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.btn {
|
|
width: 100%%;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<a href="/?mailbox=%s" class="back-btn">← 返回收件箱</a>
|
|
|
|
<div class="card">
|
|
<div class="email-header">
|
|
<div class="subject">%s</div>
|
|
<div class="meta-row">
|
|
<div class="meta-label">发件人:</div>
|
|
<div class="meta-value">%s</div>
|
|
</div>
|
|
<div class="meta-row">
|
|
<div class="meta-label">时间:</div>
|
|
<div class="meta-value">%s</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="email-body">
|
|
%s
|
|
</div>
|
|
|
|
<div class="action-bar">
|
|
<a href="/?mailbox=%s" class="btn btn-primary">返回收件箱</a>
|
|
<a href="/api/messages/%s/%s?format=raw" download="message.eml" class="btn btn-secondary">下载 EML 文件</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`
|