feat(email): 初始化自建邮箱 MVP
- 新增 Go + SQLite 后端,支持登录、域名、邮箱、别名、联系人、规则、统计与邮件收发。 - 新增 Webmail 前端,支持多邮箱切换、邮件列表/阅读/写信、附件、搜索、主题切换与个人中心。 - 新增 Docker Compose 部署骨架,补充 Postfix、Dovecot、OpenDKIM、Nginx 配置与部署说明。
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||
|
||||
func NewHTMLPolicy() *HTMLPolicy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowAttrs("style").OnElements("p", "span", "div", "table", "td", "th")
|
||||
return &HTMLPolicy{policy: p}
|
||||
}
|
||||
|
||||
func (p *HTMLPolicy) Sanitize(s string) string {
|
||||
if p == nil || p.policy == nil {
|
||||
return s
|
||||
}
|
||||
return p.policy.Sanitize(s)
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
return prefix + "_" + base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
buf := make([]byte, 32)
|
||||
_, _ = rand.Read(buf)
|
||||
return base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func normalizeDomain(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
return s
|
||||
}
|
||||
|
||||
var localPartRe = regexp.MustCompile(`[^a-z0-9._%+\-]`)
|
||||
|
||||
func normalizeLocalPart(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = localPartRe.ReplaceAllString(s, "")
|
||||
s = strings.Trim(s, ".")
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeEmail(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if !strings.Contains(s, "@") {
|
||||
return s
|
||||
}
|
||||
parts := strings.SplitN(s, "@", 2)
|
||||
return normalizeLocalPart(parts[0]) + "@" + normalizeDomain(parts[1])
|
||||
}
|
||||
|
||||
func dedupeEmails(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
email := normalizeEmail(item)
|
||||
if email == "" || !strings.Contains(email, "@") || seen[email] {
|
||||
continue
|
||||
}
|
||||
seen[email] = true
|
||||
out = append(out, email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func jsonEncode(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func jsonDecodeSlice(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, status int, msg string) {
|
||||
respondJSON(w, status, map[string]any{"error": msg})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func intBool(v int) bool { return v != 0 }
|
||||
|
||||
func parseTime(v string) time.Time {
|
||||
t, _ := time.Parse(time.RFC3339Nano, v)
|
||||
return t
|
||||
}
|
||||
|
||||
func nullableTime(v sql.NullString) *time.Time {
|
||||
if !v.Valid || v.String == "" {
|
||||
return nil
|
||||
}
|
||||
t := parseTime(v.String)
|
||||
return &t
|
||||
}
|
||||
|
||||
func snippetFrom(text, html string) string {
|
||||
s := text
|
||||
if strings.TrimSpace(s) == "" {
|
||||
s = stripTags(html)
|
||||
}
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len([]rune(s)) > 160 {
|
||||
r := []rune(s)
|
||||
s = string(r[:160]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func stripTags(s string) string {
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '<':
|
||||
inTag = true
|
||||
case '>':
|
||||
inTag = false
|
||||
default:
|
||||
if !inTag {
|
||||
if unicode.IsSpace(r) {
|
||||
b.WriteRune(' ')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func badRequest(w http.ResponseWriter, err error) {
|
||||
msg := "bad request"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
respondError(w, http.StatusBadRequest, msg)
|
||||
}
|
||||
|
||||
func requireString(name, value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s is required", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
Reference in New Issue
Block a user