一阶段ok
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"mail_go/config"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// InitDB initializes the database connection and performs auto-migration.
|
||||
// It selects the appropriate driver based on cfg.Driver and resolves
|
||||
// the DSN path for SQLite relative to the storage base directory.
|
||||
func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.DB, error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
switch cfg.Driver {
|
||||
case "sqlite":
|
||||
dsn := cfg.DSN
|
||||
// If the DSN is the default relative path, prepend the storage base directory
|
||||
if dsn == config.DefaultDSNWin || dsn == config.DefaultDSNLinux {
|
||||
dsn = filepath.Join(storageCfg.BaseDir, "mail.db")
|
||||
}
|
||||
// Ensure the parent directory exists for SQLite
|
||||
dir := filepath.Dir(dsn)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建数据库目录失败 %s: %w", dir, err)
|
||||
}
|
||||
dialector = sqlite.Open(dsn)
|
||||
case "mysql":
|
||||
dialector = mysql.Open(cfg.DSN)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库驱动: %s", cfg.Driver)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// Auto-migrate all models
|
||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}); err != nil {
|
||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents a mail user in the system.
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;not null" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
DomainID uint `gorm:"index" json:"domain_id"`
|
||||
Domain Domain `gorm:"foreignKey:DomainID" json:"domain"`
|
||||
QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"`
|
||||
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for User.
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
// Domain represents a mail domain in the system.
|
||||
type Domain struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
|
||||
SmtpPort int `gorm:"default:25" json:"smtp_port"`
|
||||
ImapPort int `gorm:"default:143" json:"imap_port"`
|
||||
Pop3Port int `gorm:"default:110" json:"pop3_port"`
|
||||
TlsCertPath string `gorm:"size:512" json:"tls_cert_path"`
|
||||
TlsKeyPath string `gorm:"size:512" json:"tls_key_path"`
|
||||
TlsEnabled bool `gorm:"default:false" json:"tls_enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for Domain.
|
||||
func (Domain) TableName() string {
|
||||
return "domains"
|
||||
}
|
||||
|
||||
// Message represents an email message in the system.
|
||||
type Message struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
MessageID string `gorm:"size:255;index" json:"message_id"`
|
||||
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
|
||||
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
|
||||
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
|
||||
CcAddr string `gorm:"size:2048" json:"cc_addr"`
|
||||
Subject string `gorm:"size:1024" json:"subject"`
|
||||
TextBody string `gorm:"type:text" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:text" json:"html_body"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
||||
Date time.Time `json:"date"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for Message.
|
||||
func (Message) TableName() string {
|
||||
return "messages"
|
||||
}
|
||||
|
||||
// Attachment represents a file attached to an email message.
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MessageID uint `gorm:"index;not null" json:"message_id"`
|
||||
Message Message `gorm:"foreignKey:MessageID" json:"message"`
|
||||
FileName string `gorm:"size:255;not null" json:"file_name"`
|
||||
FilePath string `gorm:"size:512;not null" json:"file_path"`
|
||||
ContentType string `gorm:"size:128" json:"content_type"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for Attachment.
|
||||
func (Attachment) TableName() string {
|
||||
return "attachments"
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
package imap_server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/backend"
|
||||
asgomail "github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
// ---------- imapBackend ----------
|
||||
|
||||
// imapBackend implements backend.Backend.
|
||||
type imapBackend struct {
|
||||
stores *store.Stores
|
||||
}
|
||||
|
||||
// Login authenticates a user by email and password.
|
||||
func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) {
|
||||
user, err := b.stores.Users.Authenticate(username, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials: %w", err)
|
||||
}
|
||||
|
||||
email := user.Username + "@"
|
||||
domain, err := b.stores.Domains.GetByID(user.DomainID)
|
||||
if err == nil {
|
||||
email = user.Username + "@" + domain.Name
|
||||
}
|
||||
|
||||
return &imapUser{
|
||||
stores: b.stores,
|
||||
id: user.ID,
|
||||
email: email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- imapUser ----------
|
||||
|
||||
// imapUser implements backend.User.
|
||||
type imapUser struct {
|
||||
stores *store.Stores
|
||||
id uint
|
||||
email string
|
||||
}
|
||||
|
||||
// Username returns the user's email address.
|
||||
func (u *imapUser) Username() string {
|
||||
return u.email
|
||||
}
|
||||
|
||||
// ListMailboxes returns the standard mailbox list.
|
||||
func (u *imapUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) {
|
||||
folders := []struct {
|
||||
name string
|
||||
delimiter string
|
||||
attributes []string
|
||||
}{
|
||||
{"INBOX", "/", nil},
|
||||
{"Sent", "/", nil},
|
||||
{"Drafts", "/", []string{"\\Drafts"}},
|
||||
{"Trash", "/", []string{"\\Trash"}},
|
||||
}
|
||||
|
||||
mailboxes := make([]backend.Mailbox, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
mailboxes = append(mailboxes, &imapMailbox{
|
||||
stores: u.stores,
|
||||
user: u,
|
||||
name: f.name,
|
||||
delimiter: f.delimiter,
|
||||
attributes: f.attributes,
|
||||
})
|
||||
}
|
||||
return mailboxes, nil
|
||||
}
|
||||
|
||||
// GetMailbox returns a mailbox by name.
|
||||
func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) {
|
||||
validNames := map[string]bool{
|
||||
"INBOX": true,
|
||||
"Sent": true,
|
||||
"Drafts": true,
|
||||
"Trash": true,
|
||||
}
|
||||
|
||||
normalized := name
|
||||
upper := strings.ToUpper(name)
|
||||
if upper == "INBOX" {
|
||||
normalized = "INBOX"
|
||||
} else if validNames[upper] {
|
||||
normalized = upper
|
||||
} else {
|
||||
// Try title case
|
||||
normalized = strings.Title(strings.ToLower(name))
|
||||
}
|
||||
|
||||
if !validNames[normalized] {
|
||||
return nil, backend.ErrNoSuchMailbox
|
||||
}
|
||||
|
||||
return &imapMailbox{
|
||||
stores: u.stores,
|
||||
user: u,
|
||||
name: normalized,
|
||||
delimiter: "/",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateMailbox creates a new mailbox (not supported in this version).
|
||||
func (u *imapUser) CreateMailbox(name string) error {
|
||||
return fmt.Errorf("mailbox creation not supported")
|
||||
}
|
||||
|
||||
// DeleteMailbox deletes a mailbox (not supported in this version).
|
||||
func (u *imapUser) DeleteMailbox(name string) error {
|
||||
return fmt.Errorf("mailbox deletion not supported")
|
||||
}
|
||||
|
||||
// RenameMailbox renames a mailbox (not supported in this version).
|
||||
func (u *imapUser) RenameMailbox(existingName, newName string) error {
|
||||
return fmt.Errorf("mailbox rename not supported")
|
||||
}
|
||||
|
||||
// Logout is called when the user session ends.
|
||||
func (u *imapUser) Logout() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- imapMailbox ----------
|
||||
|
||||
// imapMailbox implements backend.Mailbox.
|
||||
type imapMailbox struct {
|
||||
stores *store.Stores
|
||||
user *imapUser
|
||||
name string
|
||||
delimiter string
|
||||
attributes []string
|
||||
// deleted tracks messages marked as \Deleted in this session
|
||||
deleted map[uint]bool
|
||||
}
|
||||
|
||||
// Name returns the mailbox name.
|
||||
func (m *imapMailbox) Name() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
// Info returns mailbox metadata.
|
||||
func (m *imapMailbox) Info() (*imap.MailboxInfo, error) {
|
||||
attrs := m.attributes
|
||||
if attrs == nil {
|
||||
attrs = []string{}
|
||||
}
|
||||
return &imap.MailboxInfo{
|
||||
Name: m.name,
|
||||
Delimiter: m.delimiter,
|
||||
Attributes: attrs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Status returns mailbox status information.
|
||||
func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
|
||||
status := &imap.MailboxStatus{
|
||||
Name: m.name,
|
||||
Flags: []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"},
|
||||
PermanentFlags: []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "\\*"},
|
||||
}
|
||||
|
||||
messages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status.Messages = uint32(len(messages))
|
||||
|
||||
var unseenCount uint32
|
||||
for _, msg := range messages {
|
||||
if !msg.IsRead {
|
||||
unseenCount++
|
||||
}
|
||||
}
|
||||
status.Unseen = unseenCount
|
||||
status.Recent = 0
|
||||
status.UidNext = uint32(len(messages) + 1)
|
||||
status.UidValidity = 1
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// SetSubscribed sets the subscribed status (no-op for now).
|
||||
func (m *imapMailbox) SetSubscribed(subscribed bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check is a no-op checkpoint.
|
||||
func (m *imapMailbox) Check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMessages returns messages matching the sequence set and fetch items.
|
||||
func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error {
|
||||
defer close(ch)
|
||||
|
||||
// Fetch all messages in this mailbox
|
||||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(dbMessages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a mapping of sequence number (1-based) to db.Message
|
||||
type seqEntry struct {
|
||||
seqNum uint32
|
||||
msg *db.Message
|
||||
}
|
||||
entries := make([]seqEntry, len(dbMessages))
|
||||
for i := range dbMessages {
|
||||
entries[i] = seqEntry{
|
||||
seqNum: uint32(i + 1),
|
||||
msg: &dbMessages[i],
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
var match bool
|
||||
if uid {
|
||||
match = seqset.Contains(uint32(entry.msg.ID))
|
||||
} else {
|
||||
match = seqset.Contains(entry.seqNum)
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
|
||||
imapMsg, err := m.buildIMAPMessage(entry.msg, entry.seqNum, items)
|
||||
if err != nil {
|
||||
log.Printf("IMAP: error building message %d: %v", entry.msg.ID, err)
|
||||
continue
|
||||
}
|
||||
ch <- imapMsg
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildIMAPMessage constructs an imap.Message from a db.Message with the requested items.
|
||||
func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items []imap.FetchItem) (*imap.Message, error) {
|
||||
imapMsg := &imap.Message{
|
||||
SeqNum: seqNum,
|
||||
Uid: uint32(dbMsg.ID),
|
||||
Flags: m.getMessageFlags(dbMsg),
|
||||
InternalDate: dbMsg.Date,
|
||||
Body: make(map[*imap.BodySectionName]imap.Literal),
|
||||
}
|
||||
|
||||
rawMsg := buildRawMessage(dbMsg)
|
||||
imapMsg.Size = uint32(len(rawMsg))
|
||||
|
||||
for _, item := range items {
|
||||
switch item {
|
||||
case imap.FetchUid:
|
||||
// UID is already set on the struct
|
||||
case imap.FetchFlags:
|
||||
// Flags are already set on the struct
|
||||
case imap.FetchInternalDate:
|
||||
// InternalDate is already set on the struct
|
||||
case imap.FetchRFC822Size:
|
||||
// Size is already set on the struct
|
||||
case imap.FetchEnvelope:
|
||||
imapMsg.Envelope = m.buildEnvelope(dbMsg)
|
||||
case imap.FetchRFC822:
|
||||
section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{}}
|
||||
imapMsg.Body[section] = bytes.NewReader(rawMsg)
|
||||
case imap.FetchRFC822Header:
|
||||
headerBytes := buildRawHeader(dbMsg)
|
||||
section := &imap.BodySectionName{
|
||||
BodyPartName: imap.BodyPartName{
|
||||
Specifier: imap.HeaderSpecifier,
|
||||
},
|
||||
}
|
||||
imapMsg.Body[section] = bytes.NewReader(headerBytes)
|
||||
case imap.FetchRFC822Text:
|
||||
var bodyText string
|
||||
if dbMsg.TextBody != "" {
|
||||
bodyText = dbMsg.TextBody
|
||||
} else if dbMsg.HtmlBody != "" {
|
||||
bodyText = dbMsg.HtmlBody
|
||||
}
|
||||
section := &imap.BodySectionName{
|
||||
BodyPartName: imap.BodyPartName{
|
||||
Specifier: imap.TextSpecifier,
|
||||
},
|
||||
}
|
||||
imapMsg.Body[section] = bytes.NewReader([]byte(bodyText))
|
||||
default:
|
||||
// Handle BODY[] and BODY.PEEK[] sections
|
||||
itemStr := string(item)
|
||||
if strings.HasPrefix(itemStr, "BODY[]") || strings.HasPrefix(itemStr, "BODY.PEEK[]") {
|
||||
section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{}}
|
||||
imapMsg.Body[section] = bytes.NewReader(rawMsg)
|
||||
} else if strings.HasPrefix(itemStr, "BODY[HEADER") || strings.HasPrefix(itemStr, "BODY.PEEK[HEADER") {
|
||||
headerBytes := buildRawHeader(dbMsg)
|
||||
section := &imap.BodySectionName{
|
||||
BodyPartName: imap.BodyPartName{
|
||||
Specifier: imap.HeaderSpecifier,
|
||||
},
|
||||
}
|
||||
imapMsg.Body[section] = bytes.NewReader(headerBytes)
|
||||
} else if strings.HasPrefix(itemStr, "BODY[TEXT") || strings.HasPrefix(itemStr, "BODY.PEEK[TEXT") {
|
||||
var bodyText string
|
||||
if dbMsg.TextBody != "" {
|
||||
bodyText = dbMsg.TextBody
|
||||
} else if dbMsg.HtmlBody != "" {
|
||||
bodyText = dbMsg.HtmlBody
|
||||
}
|
||||
section := &imap.BodySectionName{
|
||||
BodyPartName: imap.BodyPartName{
|
||||
Specifier: imap.TextSpecifier,
|
||||
},
|
||||
}
|
||||
imapMsg.Body[section] = bytes.NewReader([]byte(bodyText))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imapMsg, nil
|
||||
}
|
||||
|
||||
// getMessageFlags returns IMAP flags for a database message.
|
||||
func (m *imapMailbox) getMessageFlags(dbMsg *db.Message) []string {
|
||||
flags := make([]string, 0)
|
||||
if dbMsg.IsRead {
|
||||
flags = append(flags, "\\Seen")
|
||||
}
|
||||
if dbMsg.IsFlagged {
|
||||
flags = append(flags, "\\Flagged")
|
||||
}
|
||||
if m.deleted != nil && m.deleted[dbMsg.ID] {
|
||||
flags = append(flags, "\\Deleted")
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// buildEnvelope constructs an imap.Envelope from a db.Message.
|
||||
func (m *imapMailbox) buildEnvelope(dbMsg *db.Message) *imap.Envelope {
|
||||
env := &imap.Envelope{
|
||||
Date: dbMsg.Date,
|
||||
Subject: dbMsg.Subject,
|
||||
From: parseAddressList(dbMsg.FromAddr),
|
||||
Sender: parseAddressList(dbMsg.FromAddr),
|
||||
ReplyTo: parseAddressList(dbMsg.FromAddr),
|
||||
To: parseAddressList(dbMsg.ToAddr),
|
||||
Cc: parseAddressList(dbMsg.CcAddr),
|
||||
MessageId: dbMsg.MessageID,
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// SearchMessages returns sequence numbers or UIDs of messages matching the criteria.
|
||||
func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([]uint32, error) {
|
||||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []uint32
|
||||
for i, dbMsg := range dbMessages {
|
||||
if m.matchesCriteria(&dbMsg, criteria) {
|
||||
if uid {
|
||||
results = append(results, uint32(dbMsg.ID))
|
||||
} else {
|
||||
results = append(results, uint32(i+1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// matchesCriteria checks if a message matches the given search criteria.
|
||||
func (m *imapMailbox) matchesCriteria(msg *db.Message, criteria *imap.SearchCriteria) bool {
|
||||
// Check WithFlags (messages must have all these flags)
|
||||
for _, flag := range criteria.WithFlags {
|
||||
switch flag {
|
||||
case "\\Seen":
|
||||
if !msg.IsRead {
|
||||
return false
|
||||
}
|
||||
case "\\Flagged":
|
||||
if !msg.IsFlagged {
|
||||
return false
|
||||
}
|
||||
case "\\Deleted":
|
||||
if m.deleted == nil || !m.deleted[msg.ID] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check WithoutFlags (messages must NOT have any of these flags)
|
||||
for _, flag := range criteria.WithoutFlags {
|
||||
switch flag {
|
||||
case "\\Seen":
|
||||
if msg.IsRead {
|
||||
return false
|
||||
}
|
||||
case "\\Flagged":
|
||||
if msg.IsFlagged {
|
||||
return false
|
||||
}
|
||||
case "\\Deleted":
|
||||
if m.deleted != nil && m.deleted[msg.ID] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check date range
|
||||
if !criteria.Since.IsZero() && msg.Date.Before(criteria.Since) {
|
||||
return false
|
||||
}
|
||||
if !criteria.Before.IsZero() && !msg.Date.Before(criteria.Before) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check header fields
|
||||
if criteria.Header != nil {
|
||||
if subject := criteria.Header.Get("Subject"); subject != "" {
|
||||
if !strings.Contains(strings.ToLower(msg.Subject), strings.ToLower(subject)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if from := criteria.Header.Get("From"); from != "" {
|
||||
if !strings.Contains(strings.ToLower(msg.FromAddr), strings.ToLower(from)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if to := criteria.Header.Get("To"); to != "" {
|
||||
if !strings.Contains(strings.ToLower(msg.ToAddr), strings.ToLower(to)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check body text
|
||||
for _, text := range criteria.Body {
|
||||
bodyText := strings.ToLower(msg.TextBody + " " + msg.HtmlBody)
|
||||
if !strings.Contains(bodyText, strings.ToLower(text)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check generic text (searches headers + body)
|
||||
for _, text := range criteria.Text {
|
||||
allText := strings.ToLower(msg.Subject + " " + msg.FromAddr + " " + msg.ToAddr + " " + msg.TextBody + " " + msg.HtmlBody)
|
||||
if !strings.Contains(allText, strings.ToLower(text)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check NOT criteria
|
||||
for _, notCrit := range criteria.Not {
|
||||
if m.matchesCriteria(msg, notCrit) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check OR criteria (at least one must match)
|
||||
for _, orPair := range criteria.Or {
|
||||
if !m.matchesCriteria(msg, orPair[0]) && !m.matchesCriteria(msg, orPair[1]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateMessage appends a new message to the mailbox (IMAP APPEND command).
|
||||
func (m *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error {
|
||||
// Read the message literal
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read message body: %w", err)
|
||||
}
|
||||
|
||||
// Parse as MIME message
|
||||
mr, err := asgomail.CreateReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse MIME message: %w", err)
|
||||
}
|
||||
|
||||
header := mr.Header
|
||||
fromAddr := header.Get("From")
|
||||
toAddr := header.Get("To")
|
||||
ccAddr := header.Get("Cc")
|
||||
subject, _ := header.Subject()
|
||||
messageID, _ := header.MessageID()
|
||||
msgDate, _ := header.Date()
|
||||
if msgDate.IsZero() {
|
||||
msgDate = date
|
||||
}
|
||||
if msgDate.IsZero() {
|
||||
msgDate = time.Now()
|
||||
}
|
||||
|
||||
var textBody, htmlBody string
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
switch h := p.Header.(type) {
|
||||
case *asgomail.InlineHeader:
|
||||
contentType, _, _ := h.ContentType()
|
||||
buf, _ := io.ReadAll(p.Body)
|
||||
if strings.HasPrefix(contentType, "text/plain") {
|
||||
textBody = string(buf)
|
||||
} else if strings.HasPrefix(contentType, "text/html") {
|
||||
htmlBody = string(buf)
|
||||
}
|
||||
case *asgomail.AttachmentHeader:
|
||||
// Attachments from APPEND are not saved in this simple implementation
|
||||
}
|
||||
}
|
||||
|
||||
if textBody == "" && htmlBody == "" {
|
||||
textBody = string(data)
|
||||
}
|
||||
|
||||
// Determine initial flag state
|
||||
isRead := false
|
||||
isFlagged := false
|
||||
for _, flag := range flags {
|
||||
switch flag {
|
||||
case "\\Seen":
|
||||
isRead = true
|
||||
case "\\Flagged":
|
||||
isFlagged = true
|
||||
}
|
||||
}
|
||||
|
||||
msg := &db.Message{
|
||||
UserID: m.user.id,
|
||||
MessageID: messageID,
|
||||
Folder: m.name,
|
||||
FromAddr: fromAddr,
|
||||
ToAddr: toAddr,
|
||||
CcAddr: ccAddr,
|
||||
Subject: subject,
|
||||
TextBody: textBody,
|
||||
HtmlBody: htmlBody,
|
||||
IsRead: isRead,
|
||||
IsFlagged: isFlagged,
|
||||
Date: msgDate,
|
||||
}
|
||||
|
||||
if err := m.stores.Mails.Create(msg); err != nil {
|
||||
return fmt.Errorf("failed to create message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMessagesFlags modifies flags on messages.
|
||||
func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) error {
|
||||
if m.deleted == nil {
|
||||
m.deleted = make(map[uint]bool)
|
||||
}
|
||||
|
||||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, dbMsg := range dbMessages {
|
||||
var match bool
|
||||
if uid {
|
||||
match = seqset.Contains(uint32(dbMsg.ID))
|
||||
} else {
|
||||
match = seqset.Contains(uint32(i + 1))
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, flag := range flags {
|
||||
switch flag {
|
||||
case "\\Seen":
|
||||
if op == imap.AddFlags || op == imap.SetFlags {
|
||||
_ = m.stores.Mails.MarkRead(dbMsg.ID)
|
||||
} else if op == imap.RemoveFlags {
|
||||
// Mark as unread — update directly
|
||||
}
|
||||
case "\\Flagged":
|
||||
if op == imap.AddFlags || op == imap.SetFlags {
|
||||
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, true)
|
||||
} else if op == imap.RemoveFlags {
|
||||
_ = m.stores.Mails.MarkFlagged(dbMsg.ID, false)
|
||||
}
|
||||
case "\\Deleted":
|
||||
if op == imap.AddFlags || op == imap.SetFlags {
|
||||
m.deleted[dbMsg.ID] = true
|
||||
} else if op == imap.RemoveFlags {
|
||||
delete(m.deleted, dbMsg.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyMessages copies messages to another mailbox.
|
||||
func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error {
|
||||
dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, dbMsg := range dbMessages {
|
||||
var match bool
|
||||
if uid {
|
||||
match = seqset.Contains(uint32(dbMsg.ID))
|
||||
} else {
|
||||
match = seqset.Contains(uint32(i + 1))
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a copy in the destination mailbox
|
||||
copyMsg := &db.Message{
|
||||
UserID: m.user.id,
|
||||
MessageID: dbMsg.MessageID,
|
||||
Folder: dest,
|
||||
FromAddr: dbMsg.FromAddr,
|
||||
ToAddr: dbMsg.ToAddr,
|
||||
CcAddr: dbMsg.CcAddr,
|
||||
Subject: dbMsg.Subject,
|
||||
TextBody: dbMsg.TextBody,
|
||||
HtmlBody: dbMsg.HtmlBody,
|
||||
IsRead: dbMsg.IsRead,
|
||||
IsFlagged: dbMsg.IsFlagged,
|
||||
Date: dbMsg.Date,
|
||||
}
|
||||
if err := m.stores.Mails.Create(copyMsg); err != nil {
|
||||
log.Printf("IMAP: failed to copy message %d to %s: %v", dbMsg.ID, dest, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expunge permanently removes messages marked as \Deleted.
|
||||
func (m *imapMailbox) Expunge() error {
|
||||
if m.deleted == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for msgID := range m.deleted {
|
||||
if err := m.stores.Mails.Delete(msgID); err != nil {
|
||||
log.Printf("IMAP: failed to expunge message %d: %v", msgID, err)
|
||||
}
|
||||
}
|
||||
m.deleted = make(map[uint]bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Helper functions ----------
|
||||
|
||||
// parseAddressList parses a comma-separated address string into imap.Address slice.
|
||||
func parseAddressList(addrStr string) []*imap.Address {
|
||||
if addrStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
addresses, err := mail.ParseAddressList(addrStr)
|
||||
if err != nil {
|
||||
// Fallback: treat the whole string as a single address
|
||||
return []*imap.Address{{
|
||||
MailboxName: addrStr,
|
||||
HostName: "",
|
||||
}}
|
||||
}
|
||||
|
||||
result := make([]*imap.Address, 0, len(addresses))
|
||||
for _, addr := range addresses {
|
||||
parts := strings.SplitN(addr.Address, "@", 2)
|
||||
mailbox := parts[0]
|
||||
host := ""
|
||||
if len(parts) > 1 {
|
||||
host = parts[1]
|
||||
}
|
||||
result = append(result, &imap.Address{
|
||||
PersonalName: addr.Name,
|
||||
MailboxName: mailbox,
|
||||
HostName: host,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
|
||||
func buildRawMessage(msg *db.Message) []byte {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Write headers
|
||||
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
|
||||
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
|
||||
if msg.CcAddr != "" {
|
||||
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
||||
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format(time.RFC1123Z)))
|
||||
if msg.MessageID != "" {
|
||||
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
|
||||
}
|
||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
// Write body
|
||||
if msg.HtmlBody != "" && msg.TextBody != "" {
|
||||
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
|
||||
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.TextBody)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.HtmlBody)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
} else if msg.TextBody != "" {
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.TextBody)
|
||||
} else if msg.HtmlBody != "" {
|
||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.HtmlBody)
|
||||
} else {
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// buildRawHeader reconstructs just the header portion of a raw RFC822 message.
|
||||
func buildRawHeader(msg *db.Message) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
|
||||
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
|
||||
if msg.CcAddr != "" {
|
||||
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
||||
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format(time.RFC1123Z)))
|
||||
if msg.MessageID != "" {
|
||||
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
|
||||
}
|
||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||
if msg.HtmlBody != "" && msg.TextBody != "" {
|
||||
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
|
||||
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
} else if msg.TextBody != "" {
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
} else if msg.HtmlBody != "" {
|
||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package imap_server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-imap/backend"
|
||||
imapserver "github.com/emersion/go-imap/server"
|
||||
)
|
||||
|
||||
// IMAPServer wraps a go-imap Server and provides mailbox access capability.
|
||||
type IMAPServer struct {
|
||||
stores *store.Stores
|
||||
cfg config.IMAPConfig
|
||||
}
|
||||
|
||||
// NewIMAPServer creates a new IMAP server instance.
|
||||
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores) *IMAPServer {
|
||||
return &IMAPServer{
|
||||
stores: stores,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// newServer creates a configured imapserver.Server with the given address.
|
||||
func (s *IMAPServer) newServer(addr string) *imapserver.Server {
|
||||
be := &imapBackend{stores: s.stores}
|
||||
srv := imapserver.New(be)
|
||||
srv.Addr = addr
|
||||
srv.AllowInsecureAuth = true
|
||||
return srv
|
||||
}
|
||||
|
||||
// Start starts the IMAP server on the plain-text port.
|
||||
func (s *IMAPServer) Start() error {
|
||||
srv := s.newServer(s.cfg.Addr)
|
||||
log.Printf("IMAP server listening on %s", s.cfg.Addr)
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// StartTLS starts the IMAP server on the TLS port.
|
||||
func (s *IMAPServer) StartTLS() error {
|
||||
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
|
||||
return fmt.Errorf("IMAP TLS certificate or key not configured")
|
||||
}
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load IMAP TLS certificate: %w", err)
|
||||
}
|
||||
|
||||
srv := s.newServer(s.cfg.TLSAddr)
|
||||
srv.TLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
|
||||
log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr)
|
||||
return srv.ListenAndServeTLS()
|
||||
}
|
||||
|
||||
// ensure imapBackend satisfies backend.Backend at compile time
|
||||
var _ backend.Backend = (*imapBackend)(nil)
|
||||
@@ -0,0 +1,465 @@
|
||||
package pop3_server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
)
|
||||
|
||||
// POP3Server implements a simple POP3 mail server over TCP.
|
||||
type POP3Server struct {
|
||||
listener net.Listener
|
||||
stores *store.Stores
|
||||
cfg config.POP3Config
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewPOP3Server creates a new POP3 server instance.
|
||||
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores) *POP3Server {
|
||||
return &POP3Server{
|
||||
stores: stores,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the POP3 server on the configured plain-text port.
|
||||
func (s *POP3Server) Start() error {
|
||||
var err error
|
||||
s.listener, err = net.Listen("tcp", s.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POP3 listen failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("POP3 server listening on %s", s.cfg.Addr)
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
// Listener closed
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.handleConn(conn)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartTLS starts the POP3 server on the configured TLS port.
|
||||
func (s *POP3Server) StartTLS() error {
|
||||
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
|
||||
return fmt.Errorf("POP3 TLS certificate or key not configured")
|
||||
}
|
||||
return fmt.Errorf("POP3 TLS not yet implemented")
|
||||
}
|
||||
|
||||
// handleConn handles a single POP3 client connection.
|
||||
func (s *POP3Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
// Set read/write deadlines
|
||||
conn.SetDeadline(time.Now().Add(10 * time.Minute))
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
// Session state
|
||||
var user *db.User
|
||||
var messages []pop3Message
|
||||
var deleted map[int]bool
|
||||
|
||||
// Send greeting
|
||||
sendResponse(conn, "+OK MailGo POP3 server ready")
|
||||
|
||||
// Main command loop
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return // connection closed or error
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse command
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
cmd := strings.ToUpper(parts[0])
|
||||
arg := ""
|
||||
if len(parts) > 1 {
|
||||
arg = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "USER":
|
||||
user, messages, deleted = s.handleUSER(conn, arg, user)
|
||||
case "PASS":
|
||||
user, messages, deleted = s.handlePASS(conn, arg, user)
|
||||
case "STAT":
|
||||
s.handleSTAT(conn, messages, deleted)
|
||||
case "LIST":
|
||||
s.handleLIST(conn, arg, messages, deleted)
|
||||
case "RETR":
|
||||
s.handleRETR(conn, arg, messages, deleted)
|
||||
case "DELE":
|
||||
s.handleDELE(conn, arg, deleted)
|
||||
case "NOOP":
|
||||
sendResponse(conn, "+OK")
|
||||
case "RSET":
|
||||
deleted = make(map[int]bool)
|
||||
sendResponse(conn, "+OK")
|
||||
case "QUIT":
|
||||
// In UPDATE state, actually delete marked messages
|
||||
s.expungeDeleted(messages, deleted, user)
|
||||
sendResponse(conn, "+OK MailGo POP3 server signing off")
|
||||
return
|
||||
case "CAPA":
|
||||
sendResponse(conn, "+OK Capability list follows")
|
||||
sendResponse(conn, "USER")
|
||||
sendResponse(conn, ".")
|
||||
case "TOP":
|
||||
s.handleTOP(conn, arg, messages, deleted)
|
||||
case "UIDL":
|
||||
s.handleUIDL(conn, arg, messages, deleted)
|
||||
default:
|
||||
sendResponse(conn, "-ERR unknown command")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pop3Message holds a message and its computed size for POP3.
|
||||
type pop3Message struct {
|
||||
id uint
|
||||
raw string
|
||||
size int
|
||||
message *db.Message
|
||||
}
|
||||
|
||||
// loadMessages loads all INBOX messages for a user.
|
||||
func (s *POP3Server) loadMessages(user *db.User) []pop3Message {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dbMsgs, err := s.stores.Mails.ListAllByUserAndFolder(user.ID, "INBOX")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msgs := make([]pop3Message, 0, len(dbMsgs))
|
||||
for i := range dbMsgs {
|
||||
raw := string(buildRawMessage(&dbMsgs[i]))
|
||||
msgs = append(msgs, pop3Message{
|
||||
id: dbMsgs[i].ID,
|
||||
raw: raw,
|
||||
size: len(raw),
|
||||
message: &dbMsgs[i],
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// handleUSER processes the USER command.
|
||||
func (s *POP3Server) handleUSER(conn net.Conn, username string, currentUser *db.User) (*db.User, []pop3Message, map[int]bool) {
|
||||
if username == "" {
|
||||
sendResponse(conn, "-ERR missing username")
|
||||
return currentUser, nil, nil
|
||||
}
|
||||
|
||||
// Look up the user by email
|
||||
user, err := s.stores.Users.GetByEmail(username)
|
||||
if err != nil {
|
||||
// Don't reveal whether user exists yet (standard POP3 behavior)
|
||||
sendResponse(conn, "+OK")
|
||||
// Store the attempted username for PASS to use
|
||||
return &db.User{Username: username}, nil, nil
|
||||
}
|
||||
|
||||
sendResponse(conn, "+OK")
|
||||
return user, nil, nil
|
||||
}
|
||||
|
||||
// handlePASS processes the PASS command.
|
||||
func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (*db.User, []pop3Message, map[int]bool) {
|
||||
if user == nil {
|
||||
sendResponse(conn, "-ERR no username given")
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
authUser, err := s.stores.Users.Authenticate(user.Username, password)
|
||||
if err != nil {
|
||||
// If username was an email, try using it directly
|
||||
if strings.Contains(user.Username, "@") {
|
||||
authUser, err = s.stores.Users.Authenticate(user.Username, password)
|
||||
}
|
||||
if err != nil {
|
||||
sendResponse(conn, "-ERR authentication failed")
|
||||
return nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Load messages
|
||||
messages := s.loadMessages(authUser)
|
||||
deleted := make(map[int]bool)
|
||||
|
||||
sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages)))
|
||||
return authUser, messages, deleted
|
||||
}
|
||||
|
||||
// handleSTAT processes the STAT command.
|
||||
func (s *POP3Server) handleSTAT(conn net.Conn, messages []pop3Message, deleted map[int]bool) {
|
||||
count := 0
|
||||
totalSize := 0
|
||||
for i, msg := range messages {
|
||||
if !deleted[i+1] {
|
||||
count++
|
||||
totalSize += msg.size
|
||||
}
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", count, totalSize))
|
||||
}
|
||||
|
||||
// handleLIST processes the LIST command (with optional message number).
|
||||
func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
||||
if arg == "" {
|
||||
// List all messages
|
||||
sendResponse(conn, "+OK message list follows")
|
||||
for i, msg := range messages {
|
||||
if !deleted[i+1] {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.size))
|
||||
}
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
} else {
|
||||
// List specific message
|
||||
num, err := strconv.Atoi(arg)
|
||||
if err != nil || num < 1 || num > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
if deleted[num] {
|
||||
sendResponse(conn, "-ERR message deleted")
|
||||
return
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].size))
|
||||
}
|
||||
}
|
||||
|
||||
// handleRETR processes the RETR command.
|
||||
func (s *POP3Server) handleRETR(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
||||
num, err := strconv.Atoi(arg)
|
||||
if err != nil || num < 1 || num > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
if deleted[num] {
|
||||
sendResponse(conn, "-ERR message deleted")
|
||||
return
|
||||
}
|
||||
|
||||
msg := messages[num-1]
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d octets", msg.size))
|
||||
|
||||
// Send the raw message, dot-stuffing any lines that start with "."
|
||||
scanner := bufio.NewScanner(strings.NewReader(msg.raw))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, ".") {
|
||||
conn.Write([]byte("."))
|
||||
}
|
||||
conn.Write([]byte(line + "\r\n"))
|
||||
}
|
||||
conn.Write([]byte(".\r\n"))
|
||||
}
|
||||
|
||||
// handleDELE processes the DELE command.
|
||||
func (s *POP3Server) handleDELE(conn net.Conn, arg string, deleted map[int]bool) {
|
||||
num, err := strconv.Atoi(arg)
|
||||
if err != nil || num < 1 {
|
||||
sendResponse(conn, "-ERR invalid message number")
|
||||
return
|
||||
}
|
||||
if deleted == nil {
|
||||
deleted = make(map[int]bool)
|
||||
}
|
||||
if deleted[num] {
|
||||
sendResponse(conn, "-ERR message already deleted")
|
||||
return
|
||||
}
|
||||
deleted[num] = true
|
||||
sendResponse(conn, "+OK message marked for deletion")
|
||||
}
|
||||
|
||||
// handleTOP processes the TOP command (headers + first N lines of body).
|
||||
func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
||||
// Format: TOP msgNum [n]
|
||||
parts := strings.Fields(arg)
|
||||
if len(parts) < 1 {
|
||||
sendResponse(conn, "-ERR syntax error")
|
||||
return
|
||||
}
|
||||
|
||||
num, err := strconv.Atoi(parts[0])
|
||||
if err != nil || num < 1 || num > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
if deleted != nil && deleted[num] {
|
||||
sendResponse(conn, "-ERR message deleted")
|
||||
return
|
||||
}
|
||||
|
||||
nLines := 0
|
||||
if len(parts) > 1 {
|
||||
nLines, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
|
||||
msg := messages[num-1]
|
||||
sendResponse(conn, "+OK top of message follows")
|
||||
|
||||
// Find blank line separating headers from body
|
||||
headerEnd := strings.Index(msg.raw, "\r\n\r\n")
|
||||
if headerEnd == -1 {
|
||||
headerEnd = strings.Index(msg.raw, "\n\n")
|
||||
if headerEnd == -1 {
|
||||
// No body, just send everything
|
||||
sendResponse(conn, msg.raw)
|
||||
sendResponse(conn, ".")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send headers
|
||||
header := msg.raw[:headerEnd]
|
||||
scanner := bufio.NewScanner(strings.NewReader(header))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, ".") {
|
||||
conn.Write([]byte("."))
|
||||
}
|
||||
conn.Write([]byte(line + "\r\n"))
|
||||
}
|
||||
conn.Write([]byte("\r\n"))
|
||||
|
||||
// Send up to N lines of body
|
||||
if nLines > 0 {
|
||||
body := msg.raw[headerEnd:]
|
||||
scanner := bufio.NewScanner(strings.NewReader(body))
|
||||
lineCount := 0
|
||||
for scanner.Scan() && lineCount < nLines {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, ".") {
|
||||
conn.Write([]byte("."))
|
||||
}
|
||||
conn.Write([]byte(line + "\r\n"))
|
||||
lineCount++
|
||||
}
|
||||
}
|
||||
|
||||
conn.Write([]byte(".\r\n"))
|
||||
}
|
||||
|
||||
// handleUIDL processes the UIDL command.
|
||||
func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
||||
if arg == "" {
|
||||
// List all UIDs
|
||||
sendResponse(conn, "+OK UIDL list follows")
|
||||
for i, msg := range messages {
|
||||
if deleted == nil || !deleted[i+1] {
|
||||
sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.id))
|
||||
}
|
||||
}
|
||||
sendResponse(conn, ".")
|
||||
} else {
|
||||
// Specific message UID
|
||||
num, err := strconv.Atoi(arg)
|
||||
if err != nil || num < 1 || num > len(messages) {
|
||||
sendResponse(conn, "-ERR no such message")
|
||||
return
|
||||
}
|
||||
if deleted != nil && deleted[num] {
|
||||
sendResponse(conn, "-ERR message deleted")
|
||||
return
|
||||
}
|
||||
sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].id))
|
||||
}
|
||||
}
|
||||
|
||||
// expungeDeleted actually deletes messages that were marked for deletion.
|
||||
func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) {
|
||||
if deleted == nil || user == nil {
|
||||
return
|
||||
}
|
||||
for seqNum, msgDeleted := range deleted {
|
||||
if msgDeleted && seqNum >= 1 && seqNum <= len(messages) {
|
||||
if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil {
|
||||
log.Printf("POP3: failed to delete message %d: %v", messages[seqNum-1].id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendResponse writes a POP3 response line to the connection.
|
||||
func sendResponse(conn net.Conn, line string) {
|
||||
conn.Write([]byte(line + "\r\n"))
|
||||
}
|
||||
|
||||
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
|
||||
// This is a local copy to avoid importing from the imap_server package.
|
||||
func buildRawMessage(msg *db.Message) []byte {
|
||||
var buf strings.Builder
|
||||
|
||||
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
|
||||
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
|
||||
if msg.CcAddr != "" {
|
||||
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
||||
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700")))
|
||||
if msg.MessageID != "" {
|
||||
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
|
||||
}
|
||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
if msg.HtmlBody != "" && msg.TextBody != "" {
|
||||
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
|
||||
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.TextBody)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.HtmlBody)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
} else if msg.TextBody != "" {
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.TextBody)
|
||||
} else if msg.HtmlBody != "" {
|
||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.HtmlBody)
|
||||
} else {
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
}
|
||||
|
||||
return []byte(buf.String())
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package smtp_server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/storage"
|
||||
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
// SMTPServer wraps a go-smtp Server and provides mail receiving capability.
|
||||
type SMTPServer struct {
|
||||
server *smtp.Server
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
cfg config.SMTPConfig
|
||||
}
|
||||
|
||||
// NewSMTPServer creates a new SMTP server instance.
|
||||
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *SMTPServer {
|
||||
s := &SMTPServer{
|
||||
stores: stores,
|
||||
storage: attStorage,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
be := &smtpBackend{server: s}
|
||||
srv := smtp.NewServer(be)
|
||||
srv.Addr = cfg.Addr
|
||||
srv.Domain = cfg.Domain
|
||||
srv.MaxMessageBytes = cfg.MaxMessage
|
||||
srv.AllowInsecureAuth = true
|
||||
srv.ReadTimeout = 60 * time.Second
|
||||
srv.WriteTimeout = 60 * time.Second
|
||||
|
||||
s.server = srv
|
||||
return s
|
||||
}
|
||||
|
||||
// Start starts the SMTP server on the plain-text port.
|
||||
func (s *SMTPServer) Start() error {
|
||||
log.Printf("SMTP server listening on %s", s.cfg.Addr)
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
// StartTLS starts the SMTP server on the TLS port.
|
||||
func (s *SMTPServer) StartTLS() error {
|
||||
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
|
||||
return fmt.Errorf("SMTP TLS certificate or key not configured")
|
||||
}
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load SMTP TLS certificate: %w", err)
|
||||
}
|
||||
|
||||
// 创建一个新的 SMTP 服务器实例用于 TLS 端口
|
||||
be := &smtpBackend{server: s}
|
||||
srv := smtp.NewServer(be)
|
||||
srv.Addr = s.cfg.TLSAddr
|
||||
srv.Domain = s.cfg.Domain
|
||||
srv.MaxMessageBytes = s.cfg.MaxMessage
|
||||
srv.AllowInsecureAuth = false
|
||||
srv.ReadTimeout = 60 * time.Second
|
||||
srv.WriteTimeout = 60 * time.Second
|
||||
srv.TLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
|
||||
log.Printf("SMTPS server listening on %s", s.cfg.TLSAddr)
|
||||
return srv.ListenAndServeTLS()
|
||||
}
|
||||
|
||||
// smtpBackend implements the smtp.Backend interface.
|
||||
type smtpBackend struct {
|
||||
server *SMTPServer
|
||||
}
|
||||
|
||||
// NewSession creates a new SMTP session for the incoming connection.
|
||||
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||
return &smtpSession{
|
||||
backend: be,
|
||||
rcpts: make([]string, 0),
|
||||
attachments: make([]*db.Attachment, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// smtpSession implements the smtp.Session interface for handling a single connection.
|
||||
type smtpSession struct {
|
||||
backend *smtpBackend
|
||||
from string
|
||||
rcpts []string
|
||||
authenticated bool
|
||||
username string
|
||||
attachments []*db.Attachment
|
||||
}
|
||||
|
||||
// AuthPlain authenticates the user with plain-text credentials.
|
||||
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||
user, err := s.backend.server.stores.Users.Authenticate(username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
s.authenticated = true
|
||||
s.username = user.Username
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mail records the sender address (MAIL FROM command).
|
||||
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
||||
s.from = from
|
||||
s.rcpts = s.rcpts[:0]
|
||||
s.attachments = s.attachments[:0]
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rcpt validates and records a recipient address (RCPT TO command).
|
||||
// It verifies that the recipient domain exists in the system and the user exists.
|
||||
func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||
parts := strings.SplitN(to, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid recipient address: %s", to)
|
||||
}
|
||||
|
||||
userName := parts[0]
|
||||
domainName := parts[1]
|
||||
|
||||
// Check if domain is managed by this system
|
||||
domain, err := s.backend.server.stores.Domains.GetByName(domainName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("domain not found: %s", domainName)
|
||||
}
|
||||
|
||||
// Check if the user exists in this domain
|
||||
_, err = s.backend.server.stores.Users.GetByUsername(userName, domain.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", to)
|
||||
}
|
||||
|
||||
s.rcpts = append(s.rcpts, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Data handles the message body (DATA command). It parses the MIME message,
|
||||
// extracts fields and attachments, and stores the message for each recipient.
|
||||
func (s *smtpSession) Data(r io.Reader) error {
|
||||
// Read all message data
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read message data: %w", err)
|
||||
}
|
||||
|
||||
// Parse as MIME message
|
||||
mr, err := mail.CreateReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse MIME message: %w", err)
|
||||
}
|
||||
|
||||
// Extract headers from the top-level mail header
|
||||
header := mr.Header
|
||||
|
||||
fromAddr := header.Get("From")
|
||||
toAddr := header.Get("To")
|
||||
ccAddr := header.Get("Cc")
|
||||
subject, _ := header.Subject()
|
||||
messageID, _ := header.MessageID()
|
||||
date, _ := header.Date()
|
||||
if date.IsZero() {
|
||||
date = time.Now()
|
||||
}
|
||||
|
||||
var textBody, htmlBody string
|
||||
var attachments []*db.Attachment
|
||||
|
||||
// Iterate through all MIME parts
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("SMTP: error reading MIME part: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
switch h := p.Header.(type) {
|
||||
case *mail.InlineHeader:
|
||||
contentType, _, _ := h.ContentType()
|
||||
buf, readErr := io.ReadAll(p.Body)
|
||||
if readErr != nil {
|
||||
log.Printf("SMTP: error reading inline part: %v", readErr)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(contentType, "text/plain") {
|
||||
textBody = string(buf)
|
||||
} else if strings.HasPrefix(contentType, "text/html") {
|
||||
htmlBody = string(buf)
|
||||
}
|
||||
|
||||
case *mail.AttachmentHeader:
|
||||
filename, _ := h.Filename()
|
||||
if filename == "" {
|
||||
filename = "unnamed_attachment"
|
||||
}
|
||||
contentType, _, _ := h.ContentType()
|
||||
buf, readErr := io.ReadAll(p.Body)
|
||||
if readErr != nil {
|
||||
log.Printf("SMTP: error reading attachment part: %v", readErr)
|
||||
continue
|
||||
}
|
||||
|
||||
relPath, saveErr := s.backend.server.storage.Save(filename, buf)
|
||||
if saveErr != nil {
|
||||
log.Printf("SMTP: failed to save attachment %s: %v", filename, saveErr)
|
||||
continue
|
||||
}
|
||||
|
||||
attachments = append(attachments, &db.Attachment{
|
||||
FileName: filename,
|
||||
FilePath: relPath,
|
||||
ContentType: contentType,
|
||||
FileSize: int64(len(buf)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no text body was extracted from MIME parts, use the raw data
|
||||
if textBody == "" && htmlBody == "" {
|
||||
textBody = string(data)
|
||||
}
|
||||
|
||||
// Create a Message record for each verified recipient
|
||||
for _, rcpt := range s.rcpts {
|
||||
user, err := s.backend.server.stores.Users.GetByEmail(rcpt)
|
||||
if err != nil {
|
||||
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
|
||||
continue
|
||||
}
|
||||
|
||||
msg := &db.Message{
|
||||
UserID: user.ID,
|
||||
MessageID: messageID,
|
||||
Folder: "INBOX",
|
||||
FromAddr: fromAddr,
|
||||
ToAddr: toAddr,
|
||||
CcAddr: ccAddr,
|
||||
Subject: subject,
|
||||
TextBody: textBody,
|
||||
HtmlBody: htmlBody,
|
||||
IsRead: false,
|
||||
IsFlagged: false,
|
||||
Date: date,
|
||||
}
|
||||
|
||||
if createErr := s.backend.server.stores.Mails.Create(msg); createErr != nil {
|
||||
log.Printf("SMTP: failed to create message for %s: %v", rcpt, createErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create Attachment records linked to the new message
|
||||
for _, att := range attachments {
|
||||
attCopy := db.Attachment{
|
||||
MessageID: msg.ID,
|
||||
FileName: att.FileName,
|
||||
FilePath: att.FilePath,
|
||||
ContentType: att.ContentType,
|
||||
FileSize: att.FileSize,
|
||||
}
|
||||
if attErr := s.backend.server.stores.Attachments.Create(&attCopy); attErr != nil {
|
||||
log.Printf("SMTP: failed to create attachment record: %v", attErr)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("SMTP: message delivered to %s (ID=%d)", rcpt, msg.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset clears the session state for the next message on the same connection.
|
||||
func (s *smtpSession) Reset() {
|
||||
s.from = ""
|
||||
s.rcpts = s.rcpts[:0]
|
||||
s.attachments = s.attachments[:0]
|
||||
}
|
||||
|
||||
// Logout is called when the SMTP connection is closed.
|
||||
func (s *smtpSession) Logout() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AttachmentStorage handles file operations for email attachments on disk.
|
||||
type AttachmentStorage struct {
|
||||
baseDir string // cfg.Storage.AttachDir
|
||||
}
|
||||
|
||||
// NewAttachmentStorage creates a new AttachmentStorage with the given base directory.
|
||||
func NewAttachmentStorage(baseDir string) *AttachmentStorage {
|
||||
return &AttachmentStorage{baseDir: baseDir}
|
||||
}
|
||||
|
||||
// Save writes attachment data to disk and returns the relative file path.
|
||||
// The filename is generated as {uuid}{ext} to avoid collisions.
|
||||
func (s *AttachmentStorage) Save(filename string, data []byte) (string, error) {
|
||||
// Ensure the base directory exists
|
||||
if err := os.MkdirAll(s.baseDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("创建附件目录失败: %w", err)
|
||||
}
|
||||
|
||||
// Generate a unique filename with the original extension
|
||||
ext := filepath.Ext(filename)
|
||||
uniqueName := uuid.New().String() + ext
|
||||
|
||||
fullPath := filepath.Join(s.baseDir, uniqueName)
|
||||
if err := os.WriteFile(fullPath, data, 0644); err != nil {
|
||||
return "", fmt.Errorf("写入附件文件失败: %w", err)
|
||||
}
|
||||
|
||||
return uniqueName, nil
|
||||
}
|
||||
|
||||
// Read reads attachment data from disk given a relative path.
|
||||
func (s *AttachmentStorage) Read(relPath string) ([]byte, error) {
|
||||
fullPath := s.FullPath(relPath)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取附件文件失败: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Delete removes an attachment file from disk given a relative path.
|
||||
func (s *AttachmentStorage) Delete(relPath string) error {
|
||||
fullPath := s.FullPath(relPath)
|
||||
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除附件文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FullPath returns the absolute path for a given relative path.
|
||||
func (s *AttachmentStorage) FullPath(relPath string) string {
|
||||
// Prevent directory traversal attacks
|
||||
cleanRel := filepath.Clean(relPath)
|
||||
if strings.HasPrefix(cleanRel, "..") {
|
||||
cleanRel = strings.TrimPrefix(cleanRel, "../")
|
||||
}
|
||||
return filepath.Join(s.baseDir, cleanRel)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AttachmentStore defines the interface for attachment data operations.
|
||||
type AttachmentStore interface {
|
||||
Create(att *db.Attachment) error
|
||||
GetByID(id uint) (*db.Attachment, error)
|
||||
ListByMessage(messageID uint) ([]db.Attachment, error)
|
||||
Delete(id uint) error
|
||||
DeleteByMessage(messageID uint) error
|
||||
}
|
||||
|
||||
// attachmentStoreGorm implements AttachmentStore using GORM.
|
||||
type attachmentStoreGorm struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// newAttachmentStore creates a new GORM-backed AttachmentStore.
|
||||
func newAttachmentStore(database *gorm.DB) AttachmentStore {
|
||||
return &attachmentStoreGorm{db: database}
|
||||
}
|
||||
|
||||
// Create inserts a new attachment record.
|
||||
func (s *attachmentStoreGorm) Create(att *db.Attachment) error {
|
||||
return s.db.Create(att).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves an attachment by primary key.
|
||||
func (s *attachmentStoreGorm) GetByID(id uint) (*db.Attachment, error) {
|
||||
var att db.Attachment
|
||||
if err := s.db.First(&att, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &att, nil
|
||||
}
|
||||
|
||||
// ListByMessage retrieves all attachments for a given message.
|
||||
func (s *attachmentStoreGorm) ListByMessage(messageID uint) ([]db.Attachment, error) {
|
||||
var attachments []db.Attachment
|
||||
if err := s.db.Where("message_id = ?", messageID).Find(&attachments).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
// Delete removes an attachment by ID.
|
||||
func (s *attachmentStoreGorm) Delete(id uint) error {
|
||||
return s.db.Delete(&db.Attachment{}, id).Error
|
||||
}
|
||||
|
||||
// DeleteByMessage removes all attachments for a given message.
|
||||
func (s *attachmentStoreGorm) DeleteByMessage(messageID uint) error {
|
||||
return s.db.Where("message_id = ?", messageID).Delete(&db.Attachment{}).Error
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DomainStore defines the interface for domain data operations.
|
||||
type DomainStore interface {
|
||||
Create(domain *db.Domain) error
|
||||
GetByID(id uint) (*db.Domain, error)
|
||||
GetByName(name string) (*db.Domain, error)
|
||||
Update(domain *db.Domain) error
|
||||
Delete(id uint) error
|
||||
List(page, size int) ([]db.Domain, int64, error)
|
||||
}
|
||||
|
||||
// domainStoreGorm implements DomainStore using GORM.
|
||||
type domainStoreGorm struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// newDomainStore creates a new GORM-backed DomainStore.
|
||||
func newDomainStore(database *gorm.DB) DomainStore {
|
||||
return &domainStoreGorm{db: database}
|
||||
}
|
||||
|
||||
// Create inserts a new domain record.
|
||||
func (s *domainStoreGorm) Create(domain *db.Domain) error {
|
||||
return s.db.Create(domain).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a domain by primary key.
|
||||
func (s *domainStoreGorm) GetByID(id uint) (*db.Domain, error) {
|
||||
var domain db.Domain
|
||||
if err := s.db.First(&domain, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain, nil
|
||||
}
|
||||
|
||||
// GetByName retrieves a domain by its name.
|
||||
func (s *domainStoreGorm) GetByName(name string) (*db.Domain, error) {
|
||||
var domain db.Domain
|
||||
if err := s.db.Where("name = ?", name).First(&domain).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain, nil
|
||||
}
|
||||
|
||||
// Update saves changes to an existing domain record.
|
||||
func (s *domainStoreGorm) Update(domain *db.Domain) error {
|
||||
return s.db.Save(domain).Error
|
||||
}
|
||||
|
||||
// Delete removes a domain by ID.
|
||||
func (s *domainStoreGorm) Delete(id uint) error {
|
||||
return s.db.Delete(&db.Domain{}, id).Error
|
||||
}
|
||||
|
||||
// List retrieves a paginated list of domains.
|
||||
func (s *domainStoreGorm) List(page, size int) ([]db.Domain, int64, error) {
|
||||
var domains []db.Domain
|
||||
var total int64
|
||||
|
||||
if err := s.db.Model(&db.Domain{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * size
|
||||
if err := s.db.Offset(offset).Limit(size).Find(&domains).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return domains, total, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Common store errors
|
||||
var (
|
||||
ErrInvalidEmail = errors.New("无效的邮箱地址格式")
|
||||
ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
ErrUserInactive = errors.New("用户已被禁用")
|
||||
ErrRecordNotFound = errors.New("记录不存在")
|
||||
)
|
||||
|
||||
// MailStore defines the interface for mail message operations.
|
||||
type MailStore interface {
|
||||
Create(msg *db.Message) error
|
||||
GetByID(id uint) (*db.Message, error)
|
||||
ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error)
|
||||
ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
||||
CountByUserAndFolder(userID uint, folder string) (int64, error)
|
||||
MarkRead(id uint) error
|
||||
MarkFlagged(id uint, flagged bool) error
|
||||
MoveToFolder(id uint, folder string) error
|
||||
Delete(id uint) error
|
||||
CountUnread(userID uint, folder string) (int64, error)
|
||||
}
|
||||
|
||||
// mailStoreGorm implements MailStore using GORM.
|
||||
type mailStoreGorm struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// newMailStore creates a new GORM-backed MailStore.
|
||||
func newMailStore(database *gorm.DB) MailStore {
|
||||
return &mailStoreGorm{db: database}
|
||||
}
|
||||
|
||||
// Create inserts a new message record.
|
||||
func (s *mailStoreGorm) Create(msg *db.Message) error {
|
||||
return s.db.Create(msg).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a message by primary key.
|
||||
func (s *mailStoreGorm) GetByID(id uint) (*db.Message, error) {
|
||||
var msg db.Message
|
||||
if err := s.db.First(&msg, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// ListByUserAndFolder retrieves a paginated list of messages for a user and folder.
|
||||
func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error) {
|
||||
var messages []db.Message
|
||||
var total int64
|
||||
|
||||
query := s.db.Where("user_id = ? AND folder = ?", userID, folder)
|
||||
if err := query.Model(&db.Message{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return messages, total, nil
|
||||
}
|
||||
|
||||
// MarkRead sets the IsRead flag to true for a message.
|
||||
func (s *mailStoreGorm) MarkRead(id uint) error {
|
||||
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_read", true).Error
|
||||
}
|
||||
|
||||
// MarkFlagged sets the IsFlagged flag for a message.
|
||||
func (s *mailStoreGorm) MarkFlagged(id uint, flagged bool) error {
|
||||
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_flagged", flagged).Error
|
||||
}
|
||||
|
||||
// MoveToFolder changes the folder of a message.
|
||||
func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error {
|
||||
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).Error
|
||||
}
|
||||
|
||||
// Delete removes a message by ID.
|
||||
func (s *mailStoreGorm) Delete(id uint) error {
|
||||
return s.db.Delete(&db.Message{}, id).Error
|
||||
}
|
||||
|
||||
// CountUnread returns the count of unread messages for a user in a folder.
|
||||
func (s *mailStoreGorm) CountUnread(userID uint, folder string) (int64, error) {
|
||||
var count int64
|
||||
if err := s.db.Model(&db.Message{}).
|
||||
Where("user_id = ? AND folder = ? AND is_read = ?", userID, folder, false).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
|
||||
// Messages are ordered by ID ascending so that sequence numbers are stable.
|
||||
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||
var messages []db.Message
|
||||
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
|
||||
Order("id ASC").Find(&messages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// CountByUserAndFolder returns the total count of messages for a user in a folder.
|
||||
func (s *mailStoreGorm) CountByUserAndFolder(userID uint, folder string) (int64, error) {
|
||||
var count int64
|
||||
if err := s.db.Model(&db.Message{}).
|
||||
Where("user_id = ? AND folder = ?", userID, folder).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Stores aggregates all store interfaces for convenient access.
|
||||
type Stores struct {
|
||||
Users UserStore
|
||||
Mails MailStore
|
||||
Domains DomainStore
|
||||
Attachments AttachmentStore
|
||||
}
|
||||
|
||||
// NewStores creates a new Stores instance with all GORM-backed implementations.
|
||||
func NewStores(database *gorm.DB) *Stores {
|
||||
return &Stores{
|
||||
Users: newUserStore(database),
|
||||
Mails: newMailStore(database),
|
||||
Domains: newDomainStore(database),
|
||||
Attachments: newAttachmentStore(database),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure models are referenced (prevents unused import errors).
|
||||
var _ = db.User{}
|
||||
var _ = db.Domain{}
|
||||
var _ = db.Message{}
|
||||
var _ = db.Attachment{}
|
||||
@@ -0,0 +1,146 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserStore defines the interface for user data operations.
|
||||
type UserStore interface {
|
||||
Create(user *db.User) error
|
||||
GetByID(id uint) (*db.User, error)
|
||||
GetByUsername(username string, domainID uint) (*db.User, error)
|
||||
GetByEmail(email string) (*db.User, error)
|
||||
Authenticate(email, password string) (*db.User, error)
|
||||
Update(user *db.User) error
|
||||
Delete(id uint) error
|
||||
List(domainID uint, page, size int) ([]db.User, int64, error)
|
||||
ListAll(page, size int) ([]db.User, int64, error)
|
||||
UpdateUsedBytes(id uint, delta int64) error
|
||||
UpdatePassword(userID uint, hashedPassword string) error
|
||||
}
|
||||
|
||||
// userStoreGorm implements UserStore using GORM.
|
||||
type userStoreGorm struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// newUserStore creates a new GORM-backed UserStore.
|
||||
func newUserStore(database *gorm.DB) UserStore {
|
||||
return &userStoreGorm{db: database}
|
||||
}
|
||||
|
||||
// Create inserts a new user record.
|
||||
func (s *userStoreGorm) Create(user *db.User) error {
|
||||
return s.db.Create(user).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a user by primary key.
|
||||
func (s *userStoreGorm) GetByID(id uint) (*db.User, error) {
|
||||
var user db.User
|
||||
if err := s.db.Preload("Domain").First(&user, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetByUsername retrieves a user by username and domain ID.
|
||||
func (s *userStoreGorm) GetByUsername(username string, domainID uint) (*db.User, error) {
|
||||
var user db.User
|
||||
if err := s.db.Where("username = ? AND domain_id = ?", username, domainID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves a user by email address (user@domain format).
|
||||
func (s *userStoreGorm) GetByEmail(email string) (*db.User, error) {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, ErrInvalidEmail
|
||||
}
|
||||
username := parts[0]
|
||||
domainName := parts[1]
|
||||
|
||||
var user db.User
|
||||
if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id").
|
||||
Where("users.username = ? AND domains.name = ?", username, domainName).
|
||||
Preload("Domain").
|
||||
First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Authenticate verifies an email/password combination and returns the user on success.
|
||||
func (s *userStoreGorm) Authenticate(email, password string) (*db.User, error) {
|
||||
user, err := s.GetByEmail(email)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Update saves changes to an existing user record.
|
||||
func (s *userStoreGorm) Update(user *db.User) error {
|
||||
return s.db.Save(user).Error
|
||||
}
|
||||
|
||||
// Delete removes a user by ID (soft delete if supported, hard delete otherwise).
|
||||
func (s *userStoreGorm) Delete(id uint) error {
|
||||
return s.db.Delete(&db.User{}, id).Error
|
||||
}
|
||||
|
||||
// List retrieves a paginated list of users for a given domain.
|
||||
func (s *userStoreGorm) List(domainID uint, page, size int) ([]db.User, int64, error) {
|
||||
var users []db.User
|
||||
var total int64
|
||||
|
||||
query := s.db.Where("domain_id = ?", domainID)
|
||||
if err := query.Model(&db.User{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * size
|
||||
if err := s.db.Preload("Domain").Where("domain_id = ?", domainID).Offset(offset).Limit(size).Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// UpdateUsedBytes atomically adjusts the UsedBytes field by delta.
|
||||
func (s *userStoreGorm) UpdateUsedBytes(id uint, delta int64) error {
|
||||
return s.db.Model(&db.User{}).Where("id = ?", id).
|
||||
Update("used_bytes", gorm.Expr("used_bytes + ?", delta)).Error
|
||||
}
|
||||
|
||||
// UpdatePassword updates the password hash for a user.
|
||||
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
|
||||
return s.db.Model(&db.User{}).Where("id = ?", userID).Update("password_hash", hashedPassword).Error
|
||||
}
|
||||
|
||||
// ListAll retrieves a paginated list of all users across all domains.
|
||||
func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) {
|
||||
var users []db.User
|
||||
var total int64
|
||||
|
||||
if err := s.db.Model(&db.User{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * size
|
||||
if err := s.db.Preload("Domain").Offset(offset).Limit(size).Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return users, total, nil
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// AdminHandler handles admin-related routes (dashboard, domain/user management).
|
||||
type AdminHandler struct {
|
||||
stores *store.Stores
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler with the given stores.
|
||||
func NewAdminHandler(stores *store.Stores) *AdminHandler {
|
||||
return &AdminHandler{stores: stores}
|
||||
}
|
||||
|
||||
// Dashboard renders the admin dashboard with summary statistics.
|
||||
func (h *AdminHandler) Dashboard(c *gin.Context) {
|
||||
_, domainCount, _ := h.stores.Domains.List(1, 1)
|
||||
_, userCount, _ := h.stores.Users.ListAll(1, 1)
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_dashboard", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"domainCount": domainCount,
|
||||
"userCount": userCount,
|
||||
"activeFolder": "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// ListDomains renders the domain list page.
|
||||
func (h *AdminHandler) ListDomains(c *gin.Context) {
|
||||
page := getPageParam(c, "page", 1)
|
||||
domains, total, err := h.stores.Domains.List(page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载域名列表失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "admin_domains", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"domains": domains,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"activeFolder": "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// NewDomain renders the new domain form page.
|
||||
func (h *AdminHandler) NewDomain(c *gin.Context) {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_domain_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "",
|
||||
"isEdit": false,
|
||||
"domain": &db.Domain{},
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDomain processes the new domain form submission.
|
||||
func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
||||
name := c.PostForm("name")
|
||||
smtpPort := formIntOrDefault(c, "smtp_port", 25)
|
||||
imapPort := formIntOrDefault(c, "imap_port", 143)
|
||||
pop3Port := formIntOrDefault(c, "pop3_port", 110)
|
||||
tlsEnabled := c.PostForm("tls_enabled") == "on"
|
||||
|
||||
if name == "" {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "请输入域名",
|
||||
"isEdit": false,
|
||||
"domain": &db.Domain{
|
||||
Name: name,
|
||||
SmtpPort: smtpPort,
|
||||
ImapPort: imapPort,
|
||||
Pop3Port: pop3Port,
|
||||
TlsEnabled: tlsEnabled,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
domain := &db.Domain{
|
||||
Name: name,
|
||||
SmtpPort: smtpPort,
|
||||
ImapPort: imapPort,
|
||||
Pop3Port: pop3Port,
|
||||
TlsEnabled: tlsEnabled,
|
||||
}
|
||||
|
||||
if err := h.stores.Domains.Create(domain); err != nil {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": fmt.Sprintf("创建域名失败: %v", err),
|
||||
"isEdit": false,
|
||||
"domain": domain,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/domains")
|
||||
}
|
||||
|
||||
// DeleteDomain removes a domain by ID.
|
||||
func (h *AdminHandler) DeleteDomain(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Domains.Delete(uint(id)); err != nil {
|
||||
c.String(http.StatusInternalServerError, "删除域名失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/domains")
|
||||
}
|
||||
|
||||
// DNSHint renders the DNS configuration hints for a specific domain.
|
||||
func (h *AdminHandler) DNSHint(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
||||
return
|
||||
}
|
||||
|
||||
domain, err := h.stores.Domains.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "域名不存在")
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_dns_hint", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"domain": domain,
|
||||
})
|
||||
}
|
||||
|
||||
// ListUsers renders the user list page.
|
||||
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
users, total, err := h.stores.Users.ListAll(page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载用户列表失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all domains for display
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "admin_users", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"users": users,
|
||||
"domains": domains,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"activeFolder": "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// NewUser renders the new user form page.
|
||||
func (h *AdminHandler) NewUser(c *gin.Context) {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "",
|
||||
"isEdit": false,
|
||||
"domains": domains,
|
||||
"user": &db.User{},
|
||||
})
|
||||
}
|
||||
|
||||
// CreateUser processes the new user form submission.
|
||||
func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
domainID := formUintOrDefault(c, "domain_id", 0)
|
||||
quotaGB := formIntOrDefault(c, "quota_gb", 5)
|
||||
isAdmin := c.PostForm("is_admin") == "on"
|
||||
|
||||
if username == "" || password == "" || domainID == 0 {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "请填写所有必填字段",
|
||||
"isEdit": false,
|
||||
"domains": domains,
|
||||
"user": &db.User{
|
||||
Username: username,
|
||||
DomainID: domainID,
|
||||
IsAdmin: isAdmin,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "密码加密失败",
|
||||
"isEdit": false,
|
||||
"domains": domains,
|
||||
"user": &db.User{
|
||||
Username: username,
|
||||
DomainID: domainID,
|
||||
IsAdmin: isAdmin,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
quotaBytes := int64(quotaGB) * 1024 * 1024 * 1024
|
||||
|
||||
user := &db.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hashedPassword),
|
||||
DomainID: domainID,
|
||||
QuotaBytes: quotaBytes,
|
||||
UsedBytes: 0,
|
||||
IsActive: true,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
if err := h.stores.Users.Create(user); err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": fmt.Sprintf("创建用户失败: %v", err),
|
||||
"isEdit": false,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
// DeleteUser removes a user by ID.
|
||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
if currentUser.(*db.User).ID == uint(id) {
|
||||
c.String(http.StatusBadRequest, "不能删除自己的账户")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Users.Delete(uint(id)); err != nil {
|
||||
c.String(http.StatusInternalServerError, "删除用户失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
// EditUser renders the edit user form page.
|
||||
func (h *AdminHandler) EditUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "",
|
||||
"isEdit": true,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUser processes the edit user form submission.
|
||||
func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
username := c.PostForm("username")
|
||||
domainID := formUintOrDefault(c, "domain_id", user.DomainID)
|
||||
quotaGB := formIntOrDefault(c, "quota_gb", int(user.QuotaBytes/(1024*1024*1024)))
|
||||
isActive := c.PostForm("is_active") == "on"
|
||||
isAdmin := c.PostForm("is_admin") == "on"
|
||||
password := c.PostForm("password")
|
||||
|
||||
if username != "" {
|
||||
user.Username = username
|
||||
}
|
||||
user.DomainID = domainID
|
||||
user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024
|
||||
user.IsActive = isActive
|
||||
user.IsAdmin = isAdmin
|
||||
|
||||
// Update password only if a new one is provided
|
||||
if password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": "密码加密失败",
|
||||
"isEdit": true,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
}
|
||||
|
||||
if err := h.stores.Users.Update(user); err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "admin",
|
||||
"error": fmt.Sprintf("更新用户失败: %v", err),
|
||||
"isEdit": true,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
||||
func formIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
||||
val := c.PostForm(key)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
n, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// formUintOrDefault extracts a uint from a form field, returning the default if missing/invalid.
|
||||
func formUintOrDefault(c *gin.Context, key string, defaultVal uint) uint {
|
||||
val := c.PostForm(key)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
n, err := strconv.ParseUint(val, 10, 64)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return uint(n)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related routes (login, logout).
|
||||
type AuthHandler struct {
|
||||
stores *store.Stores
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new AuthHandler with the given stores.
|
||||
func NewAuthHandler(stores *store.Stores) *AuthHandler {
|
||||
return &AuthHandler{stores: stores}
|
||||
}
|
||||
|
||||
// ShowLogin renders the login page.
|
||||
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||||
// If already logged in, redirect to inbox
|
||||
session := sessions.Default(c)
|
||||
if session.Get("userID") != nil {
|
||||
c.Redirect(302, "/inbox")
|
||||
return
|
||||
}
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "",
|
||||
})
|
||||
}
|
||||
|
||||
// DoLogin processes the login form submission.
|
||||
// It authenticates the user with email and password, sets session data
|
||||
// on success, or re-renders the login page with an error on failure.
|
||||
func (h *AuthHandler) DoLogin(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
|
||||
if email == "" || password == "" {
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "请输入邮箱和密码",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.Authenticate(email, password)
|
||||
if err != nil {
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set session values
|
||||
session := sessions.Default(c)
|
||||
session.Set("userID", user.ID)
|
||||
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||
session.Set("isAdmin", user.IsAdmin)
|
||||
if err := session.Save(); err != nil {
|
||||
c.HTML(200, "login", gin.H{
|
||||
"error": "会话保存失败,请重试",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(302, "/inbox")
|
||||
}
|
||||
|
||||
// DoLogout clears the session and redirects to the login page.
|
||||
func (h *AuthHandler) DoLogout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(302, "/login")
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// MailHandler handles mail-related routes (inbox, compose, sent, view, etc.).
|
||||
type MailHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
}
|
||||
|
||||
// NewMailHandler creates a new MailHandler with the given stores and attachment storage.
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage}
|
||||
}
|
||||
|
||||
// Inbox renders the inbox page showing all messages in the user's INBOX folder.
|
||||
func (h *MailHandler) Inbox(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "INBOX", page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
unreadCount, _ := h.stores.Mails.CountUnread(userID, "INBOX")
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "inbox", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"unreadCount": unreadCount,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"folder": "INBOX",
|
||||
"activeFolder": "inbox",
|
||||
})
|
||||
}
|
||||
|
||||
// View renders the email detail page for a specific message.
|
||||
func (h *MailHandler) View(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "邮件不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the message belongs to the current user
|
||||
if msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
|
||||
// Load attachments
|
||||
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
|
||||
|
||||
// Auto mark as read
|
||||
if !msg.IsRead {
|
||||
_ = h.stores.Mails.MarkRead(uint(id))
|
||||
msg.IsRead = true
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "view", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"message": msg,
|
||||
"attachments": attachments,
|
||||
"activeFolder": resolveActiveFolder(msg.Folder),
|
||||
})
|
||||
}
|
||||
|
||||
// Compose renders the email composition page.
|
||||
func (h *MailHandler) Compose(c *gin.Context) {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
c.HTML(200, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "",
|
||||
"to": c.Query("to"),
|
||||
"subject": c.Query("subject"),
|
||||
})
|
||||
}
|
||||
|
||||
// DoSend processes the email composition form, sends the email via SMTP,
|
||||
// and stores the message record.
|
||||
func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
currentUserVal, _ := c.Get("currentUser")
|
||||
currentUser := currentUserVal.(*db.User)
|
||||
|
||||
to := c.PostForm("to")
|
||||
subject := c.PostForm("subject")
|
||||
body := c.PostForm("body")
|
||||
cc := c.PostForm("cc")
|
||||
|
||||
if to == "" {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "请输入收件人",
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the email content
|
||||
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
|
||||
now := time.Now()
|
||||
messageID := fmt.Sprintf("<%s@mail_go>", uuid.New().String())
|
||||
|
||||
// Construct the raw email message
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("From: %s\r\n", fromAddr))
|
||||
sb.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||
if cc != "" {
|
||||
sb.WriteString(fmt.Sprintf("Cc: %s\r\n", cc))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
|
||||
sb.WriteString("MIME-Version: 1.0\r\n")
|
||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
sb.WriteString("\r\n")
|
||||
sb.WriteString(body)
|
||||
|
||||
// Send via local SMTP
|
||||
err := smtp.SendMail("localhost:25", nil, fromAddr, strings.Split(to, ","), []byte(sb.String()))
|
||||
if err != nil {
|
||||
// Log the error but still save to sent folder — SMTP may not be running yet
|
||||
fmt.Printf("SMTP发送失败(邮件仍保存到发件箱): %v\n", err)
|
||||
}
|
||||
|
||||
// Save to Sent folder
|
||||
msg := &db.Message{
|
||||
UserID: userID,
|
||||
MessageID: messageID,
|
||||
Folder: "Sent",
|
||||
FromAddr: fromAddr,
|
||||
ToAddr: to,
|
||||
CcAddr: cc,
|
||||
Subject: subject,
|
||||
TextBody: body,
|
||||
Date: now,
|
||||
IsRead: true,
|
||||
}
|
||||
|
||||
if createErr := h.stores.Mails.Create(msg); createErr != nil {
|
||||
c.HTML(http.StatusInternalServerError, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("保存邮件失败: %v", createErr),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
form, err := c.MultipartForm()
|
||||
if err == nil {
|
||||
files := form.File["attachments"]
|
||||
for _, file := range files {
|
||||
// Read file content
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Save to disk
|
||||
relPath, err := h.storage.Save(file.Filename, buf)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine content type from extension
|
||||
contentType := "application/octet-stream"
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if ct, ok := mimeTypes[ext]; ok {
|
||||
contentType = ct
|
||||
}
|
||||
|
||||
att := &db.Attachment{
|
||||
MessageID: msg.ID,
|
||||
FileName: file.Filename,
|
||||
FilePath: relPath,
|
||||
ContentType: contentType,
|
||||
FileSize: file.Size,
|
||||
}
|
||||
_ = h.stores.Attachments.Create(att)
|
||||
}
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/sent")
|
||||
}
|
||||
|
||||
// mimeTypes maps common file extensions to MIME types.
|
||||
var mimeTypes = map[string]string{
|
||||
".txt": "text/plain",
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".zip": "application/zip",
|
||||
".rar": "application/x-rar-compressed",
|
||||
".csv": "text/csv",
|
||||
}
|
||||
|
||||
// Sent renders the sent mail folder page.
|
||||
func (h *MailHandler) Sent(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Sent", page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载发件箱失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "sent", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"folder": "Sent",
|
||||
"activeFolder": "sent",
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes a message by ID after verifying ownership.
|
||||
func (h *MailHandler) Delete(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||
if err != nil || msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete attachments on disk and in DB
|
||||
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
|
||||
for _, att := range attachments {
|
||||
_ = h.storage.Delete(att.FilePath)
|
||||
}
|
||||
_ = h.stores.Attachments.DeleteByMessage(uint(id))
|
||||
_ = h.stores.Mails.Delete(uint(id))
|
||||
|
||||
// Redirect back based on the folder
|
||||
referer := c.GetHeader("Referer")
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
|
||||
// MarkRead marks a message as read.
|
||||
func (h *MailHandler) MarkRead(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||
if err != nil || msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.stores.Mails.MarkRead(uint(id))
|
||||
|
||||
referer := c.GetHeader("Referer")
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
|
||||
// DownloadAttachment serves an attachment file for download.
|
||||
func (h *MailHandler) DownloadAttachment(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "无效的附件ID")
|
||||
return
|
||||
}
|
||||
|
||||
att, err := h.stores.Attachments.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "附件不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the message belongs to the current user
|
||||
msg, err := h.stores.Mails.GetByID(att.MessageID)
|
||||
if err != nil || msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.storage.Read(att.FilePath)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "读取附件失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", att.FileName))
|
||||
c.Data(http.StatusOK, att.ContentType, data)
|
||||
}
|
||||
|
||||
// getPageParam extracts and validates a page query parameter.
|
||||
// Returns defaultVal if the parameter is missing or invalid.
|
||||
func getPageParam(c *gin.Context, key string, defaultVal int) int {
|
||||
pageStr := c.Query(key)
|
||||
if pageStr == "" {
|
||||
return defaultVal
|
||||
}
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
return defaultVal
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// Drafts renders the drafts folder page.
|
||||
func (h *MailHandler) Drafts(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Drafts", page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载草稿箱失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "drafts", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"folder": "Drafts",
|
||||
"activeFolder": "drafts",
|
||||
})
|
||||
}
|
||||
|
||||
// Settings renders the user settings page.
|
||||
func (h *MailHandler) Settings(c *gin.Context) {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(200, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "",
|
||||
"success": "",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSettings handles the password change form.
|
||||
func (h *MailHandler) UpdateSettings(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
currentUserVal, _ := c.Get("currentUser")
|
||||
currentUser := currentUserVal.(*db.User)
|
||||
|
||||
oldPassword := c.PostForm("old_password")
|
||||
newPassword := c.PostForm("new_password")
|
||||
confirmPassword := c.PostForm("confirm_password")
|
||||
|
||||
// Verify old password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "当前密码不正确",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword == "" {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "新密码不能为空",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "两次输入的密码不一致",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusInternalServerError, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "密码加密失败",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
|
||||
c.HTML(http.StatusInternalServerError, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "密码更新失败",
|
||||
"success": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "",
|
||||
"success": "密码修改成功",
|
||||
})
|
||||
}
|
||||
|
||||
// resolveActiveFolder maps a folder name to a sidebar active state key.
|
||||
func resolveActiveFolder(folder string) string {
|
||||
switch folder {
|
||||
case "INBOX":
|
||||
return "inbox"
|
||||
case "Sent":
|
||||
return "sent"
|
||||
case "Drafts":
|
||||
return "drafts"
|
||||
default:
|
||||
return folder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminMiddleware checks that the current user has admin privileges.
|
||||
// Must be used after AuthMiddleware so that "currentUser" is available.
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get("currentUser")
|
||||
if !exists {
|
||||
c.String(http.StatusForbidden, "禁止访问")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userVal.(*db.User)
|
||||
if !ok || !user.IsAdmin {
|
||||
c.String(http.StatusForbidden, "禁止访问:需要管理员权限")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware checks for a valid session and loads the current user
|
||||
// into the Gin context. If no valid session exists, it redirects to /login.
|
||||
func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("userID")
|
||||
if userID == nil {
|
||||
c.Redirect(302, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// userID is stored as uint in session, but sessions.Get returns interface{}
|
||||
// which may be stored as int or uint depending on the underlying store.
|
||||
var id uint
|
||||
switch v := userID.(type) {
|
||||
case uint:
|
||||
id = v
|
||||
case int:
|
||||
id = uint(v)
|
||||
case int64:
|
||||
id = uint(v)
|
||||
case float64:
|
||||
id = uint(v)
|
||||
default:
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(302, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, err := stores.Users.GetByID(id)
|
||||
if err != nil {
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(302, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("currentUser", user)
|
||||
c.Set("userID", id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/web/handlers"
|
||||
"mail_go/internal/web/middleware"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// formatBytes converts a file size in bytes to a human-readable string.
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// WebServer wraps the Gin engine and its dependencies.
|
||||
type WebServer struct {
|
||||
engine *gin.Engine
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
cfg config.WebConfig
|
||||
}
|
||||
|
||||
// templateFuncs returns custom template functions for rendering.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"mul": func(a, b int) int { return a * b },
|
||||
"div": func(a, b int) int { return a / b },
|
||||
"mod": func(a, b int) int { return a % b },
|
||||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
||||
"seq": func(n int) []int {
|
||||
result := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
result[i] = i + 1
|
||||
}
|
||||
return result
|
||||
},
|
||||
"domainName": func(domainID uint, domains []interface{}) string {
|
||||
return fmt.Sprintf("Domain #%d", domainID)
|
||||
},
|
||||
"safeHTML": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"formatBytes": func(b int64) string {
|
||||
return formatBytes(b)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||||
// configures sessions, middleware, and registers all routes.
|
||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *WebServer {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(gin.Logger())
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
// Session store (cookie-based)
|
||||
cookieStore := cookie.NewStore([]byte("mail-go-secret-key-change-in-production"))
|
||||
cookieStore.Options(sessions.Options{
|
||||
HttpOnly: true,
|
||||
SameSite: 3, // SameSiteLaxMode
|
||||
MaxAge: 86400,
|
||||
Path: "/",
|
||||
})
|
||||
engine.Use(sessions.Sessions("mail_go_session", cookieStore))
|
||||
|
||||
// Load HTML templates with custom functions
|
||||
// Note: Go's filepath.Glob doesn't support **, so we load in two passes
|
||||
tmpl := template.Must(template.New("").Funcs(templateFuncs()).ParseGlob("internal/web/templates/*.html"))
|
||||
template.Must(tmpl.ParseGlob("internal/web/templates/admin/*.html"))
|
||||
engine.SetHTMLTemplate(tmpl)
|
||||
|
||||
ws := &WebServer{
|
||||
engine: engine,
|
||||
stores: stores,
|
||||
storage: attStorage,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
ws.registerRoutes()
|
||||
return ws
|
||||
}
|
||||
|
||||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||
func (ws *WebServer) registerRoutes() {
|
||||
authHandler := handlers.NewAuthHandler(ws.stores)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores)
|
||||
|
||||
// Public routes (no auth required)
|
||||
ws.engine.GET("/login", authHandler.ShowLogin)
|
||||
ws.engine.POST("/login", authHandler.DoLogin)
|
||||
|
||||
// Auth-protected routes
|
||||
auth := ws.engine.Group("")
|
||||
auth.Use(middleware.AuthMiddleware(ws.stores))
|
||||
{
|
||||
auth.POST("/logout", authHandler.DoLogout)
|
||||
auth.GET("/", func(c *gin.Context) {
|
||||
c.Redirect(302, "/inbox")
|
||||
})
|
||||
|
||||
// Mail routes
|
||||
auth.GET("/inbox", mailHandler.Inbox)
|
||||
auth.GET("/inbox/:id", mailHandler.View)
|
||||
auth.GET("/compose", mailHandler.Compose)
|
||||
auth.POST("/compose", mailHandler.DoSend)
|
||||
auth.GET("/drafts", mailHandler.Drafts)
|
||||
auth.GET("/drafts/:id", mailHandler.View)
|
||||
auth.GET("/sent", mailHandler.Sent)
|
||||
auth.GET("/sent/:id", mailHandler.View)
|
||||
auth.GET("/settings", mailHandler.Settings)
|
||||
auth.POST("/settings", mailHandler.UpdateSettings)
|
||||
auth.POST("/mail/delete/:id", mailHandler.Delete)
|
||||
auth.POST("/mail/read/:id", mailHandler.MarkRead)
|
||||
auth.GET("/attachment/:id", mailHandler.DownloadAttachment)
|
||||
}
|
||||
|
||||
// Admin routes (auth + admin required)
|
||||
admin := ws.engine.Group("/admin")
|
||||
admin.Use(middleware.AuthMiddleware(ws.stores))
|
||||
admin.Use(middleware.AdminMiddleware())
|
||||
{
|
||||
admin.GET("", adminHandler.Dashboard)
|
||||
admin.GET("/", adminHandler.Dashboard)
|
||||
admin.GET("/domains", adminHandler.ListDomains)
|
||||
admin.GET("/domains/new", adminHandler.NewDomain)
|
||||
admin.POST("/domains", adminHandler.CreateDomain)
|
||||
admin.POST("/domains/:id/delete", adminHandler.DeleteDomain)
|
||||
admin.GET("/domains/:id/dns", adminHandler.DNSHint)
|
||||
admin.GET("/users", adminHandler.ListUsers)
|
||||
admin.GET("/users/new", adminHandler.NewUser)
|
||||
admin.POST("/users", adminHandler.CreateUser)
|
||||
admin.POST("/users/:id/delete", adminHandler.DeleteUser)
|
||||
admin.GET("/users/:id/edit", adminHandler.EditUser)
|
||||
admin.POST("/users/:id", adminHandler.UpdateUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the HTTP server on the configured address.
|
||||
func (ws *WebServer) Start() error {
|
||||
return ws.engine.Run(ws.cfg.Addr)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "admin_dashboard"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin" class="active">控制面板</a>
|
||||
<a href="/admin/domains">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2 style="margin-bottom:24px;">管理后台</h2>
|
||||
<div style="margin-bottom:24px;">
|
||||
<div class="stat-card">
|
||||
<h3>{{.domainCount}}</h3>
|
||||
<p>域名数</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{.userCount}}</h3>
|
||||
<p>用户数</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>快捷操作</h3>
|
||||
<p style="margin-top:12px;">
|
||||
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
|
||||
<a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">新增用户</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "admin_dns_hint"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DNS配置 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains" class="active">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">DNS 配置提示 — {{.domain.Name}}</h2>
|
||||
<p style="margin-bottom:16px;color:#7f8c8d;">请在您的 DNS 服务商处添加以下记录:</p>
|
||||
|
||||
<h4 style="margin-bottom:8px;">MX 记录</h4>
|
||||
<div class="dns-record">@ IN MX 10 mail.{{.domain.Name}}.</div>
|
||||
|
||||
<h4 style="margin-bottom:8px;">SPF 记录 (TXT)</h4>
|
||||
<div class="dns-record">@ IN TXT "v=spf1 mx -all"</div>
|
||||
|
||||
<h4 style="margin-bottom:8px;">DKIM 记录</h4>
|
||||
<div class="dns-record" style="color:#e67e22;">⚠️ 请在域名配置页配置 DKIM 私钥路径后,将自动生成 DKIM 公钥记录。</div>
|
||||
|
||||
<h4 style="margin-bottom:8px;">DMARC 记录</h4>
|
||||
<div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
|
||||
|
||||
<div style="margin-top:24px;">
|
||||
<a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">返回域名列表</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,56 @@
|
||||
{{define "admin_domain_form"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains" class="active">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑域名{{else}}新增域名{{end}}</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
|
||||
<div class="form-group">
|
||||
<label>域名</label>
|
||||
<input type="text" name="name" required value="{{.domain.Name}}" placeholder="example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SMTP 端口</label>
|
||||
<input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>IMAP 端口</label>
|
||||
<input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>POP3 端口</label>
|
||||
<input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}}>
|
||||
启用 TLS
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button>
|
||||
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,79 @@
|
||||
{{define "admin_domains"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>域名管理 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains" class="active">域名管理</a>
|
||||
<a href="/admin/users">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2>域名列表</h2>
|
||||
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
|
||||
</div>
|
||||
{{if not .domains}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无域名</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>域名</th>
|
||||
<th>SMTP</th>
|
||||
<th>IMAP</th>
|
||||
<th>POP3</th>
|
||||
<th>TLS</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .domains}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.SmtpPort}}</td>
|
||||
<td>{{.ImapPort}}</td>
|
||||
<td>{{.Pop3Port}}</td>
|
||||
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
|
||||
<td>
|
||||
<a href="/admin/domains/{{.ID}}/dns" class="btn btn-primary btn-sm">DNS</a>
|
||||
<form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/admin/domains?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/admin/domains?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,69 @@
|
||||
{{define "admin_user_form"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{if .isEdit}}编辑用户{{else}}新增用户{{end}} - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains">域名管理</a>
|
||||
<a href="/admin/users" class="active">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑用户{{else}}新增用户{{end}}</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" required value="{{.user.Username}}" placeholder="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{if .isEdit}}新密码(留空则不修改){{else}}密码{{end}}</label>
|
||||
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="请输入密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>域名</label>
|
||||
<select name="domain_id" required>
|
||||
<option value="">请选择域名</option>
|
||||
{{range .domains}}
|
||||
<option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>配额 (GB)</label>
|
||||
<input type="number" name="quota_gb" min="1" value="5" placeholder="5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}>
|
||||
管理员
|
||||
</label>
|
||||
</div>
|
||||
{{if .isEdit}}
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}>
|
||||
启用账户
|
||||
</label>
|
||||
</div>
|
||||
{{end}}
|
||||
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建用户{{end}}</button>
|
||||
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,81 @@
|
||||
{{define "admin_users"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户管理 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox">返回邮箱</a>
|
||||
<a href="/admin">控制面板</a>
|
||||
<a href="/admin/domains">域名管理</a>
|
||||
<a href="/admin/users" class="active">用户管理</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2>用户列表</h2>
|
||||
<a href="/admin/users/new" class="btn btn-primary">新增用户</a>
|
||||
</div>
|
||||
{{if not .users}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无用户</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>域名ID</th>
|
||||
<th>配额</th>
|
||||
<th>已用</th>
|
||||
<th>状态</th>
|
||||
<th>管理员</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .users}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.DomainID}}</td>
|
||||
<td>{{formatBytes .QuotaBytes}}</td>
|
||||
<td>{{formatBytes .UsedBytes}}</td>
|
||||
<td>{{if .IsActive}}✅{{else}}❌{{end}}</td>
|
||||
<td>{{if .IsAdmin}}✅{{else}}—{{end}}</td>
|
||||
<td>
|
||||
<a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a>
|
||||
<form method="POST" action="/admin/users/{{.ID}}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除用户 {{.Username}} 吗?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/admin/users?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/admin/users?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,66 @@
|
||||
{{define "styles"}}
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:#f5f5f5; color:#333; }
|
||||
.navbar { background:#2c3e50; padding:0 20px; height:50px; display:flex; align-items:center; }
|
||||
.navbar a { color:#ecf0f1; text-decoration:none; margin-right:20px; font-size:14px; }
|
||||
.navbar a:hover { color:#3498db; }
|
||||
.navbar .right { margin-left:auto; }
|
||||
.container { max-width:1200px; margin:20px auto; padding:0 20px; }
|
||||
.card { background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); padding:20px; margin-bottom:20px; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #eee; }
|
||||
th { background:#f8f9fa; font-weight:600; }
|
||||
.btn { display:inline-block; padding:8px 16px; border-radius:4px; text-decoration:none; font-size:14px; cursor:pointer; border:none; }
|
||||
.btn-primary { background:#3498db; color:#fff; }
|
||||
.btn-primary:hover { background:#2980b9; }
|
||||
.btn-danger { background:#e74c3c; color:#fff; }
|
||||
.btn-danger:hover { background:#c0392b; }
|
||||
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||
.alert { padding:12px 16px; border-radius:4px; margin-bottom:16px; }
|
||||
.alert-error { background:#fde8e8; color:#c0392b; }
|
||||
.alert-success { background:#e8fde8; color:#27ae60; }
|
||||
.form-group { margin-bottom:16px; }
|
||||
.form-group label { display:block; margin-bottom:6px; font-weight:600; }
|
||||
.form-group input, .form-group textarea, .form-group select { width:100%; padding:8px 12px; border:1px solid #ddd; border-radius:4px; font-size:14px; }
|
||||
.form-group input[type="checkbox"] { width:auto; }
|
||||
.unread { font-weight:bold; }
|
||||
.message-subject { color:#2c3e50; text-decoration:none; }
|
||||
.message-subject:hover { color:#3498db; }
|
||||
.sidebar { width:200px; float:left; }
|
||||
.sidebar a { display:block; padding:10px 15px; color:#2c3e50; text-decoration:none; border-radius:4px; margin-bottom:2px; }
|
||||
.sidebar a:hover, .sidebar a.active { background:#3498db; color:#fff; }
|
||||
.content { margin-left:220px; }
|
||||
.pagination { margin-top:16px; text-align:center; }
|
||||
.pagination a, .pagination span { display:inline-block; padding:6px 12px; margin:0 2px; border:1px solid #ddd; border-radius:4px; text-decoration:none; color:#333; }
|
||||
.pagination .current { background:#3498db; color:#fff; border-color:#3498db; }
|
||||
.mail-meta { color:#7f8c8d; font-size:13px; margin-bottom:8px; }
|
||||
.mail-body { line-height:1.6; margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
|
||||
.attachment-list { margin-top:16px; padding-top:16px; border-top:1px solid #eee; }
|
||||
.attachment-item { display:inline-block; margin-right:12px; margin-bottom:8px; padding:6px 12px; background:#ecf0f1; border-radius:4px; font-size:13px; }
|
||||
.attachment-item a { color:#2c3e50; text-decoration:none; }
|
||||
.attachment-item a:hover { color:#3498db; }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:bold; }
|
||||
.badge-unread { background:#e74c3c; color:#fff; }
|
||||
.stat-card { display:inline-block; width:200px; padding:20px; margin-right:20px; background:#fff; border-radius:8px; box-shadow:0 2px 4px rgba(0,0,0,0.1); text-align:center; }
|
||||
.stat-card h3 { font-size:32px; color:#2c3e50; margin-bottom:4px; }
|
||||
.stat-card p { color:#7f8c8d; font-size:14px; }
|
||||
.dns-record { background:#f8f9fa; padding:12px 16px; border-radius:4px; margin-bottom:12px; font-family:monospace; font-size:13px; white-space:pre-wrap; }
|
||||
.clearfix::after { content:""; display:table; clear:both; }
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "navbar"}}
|
||||
{{if .currentUser}}
|
||||
<nav class="navbar">
|
||||
<a href="/inbox">MailGo</a>
|
||||
{{if .currentUser.IsAdmin}}<a href="/admin">管理后台</a>{{end}}
|
||||
<div class="right">
|
||||
<span style="color:#ecf0f1;font-size:13px;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span>
|
||||
<form method="POST" action="/logout" style="display:inline;">
|
||||
<a href="#" onclick="this.parentElement.submit(); return false;">退出</a>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,55 @@
|
||||
{{define "compose"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>撰写邮件 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">撰写邮件</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/compose" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label>收件人</label>
|
||||
<input type="email" name="to" required value="{{.to}}" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>抄送(可选)</label>
|
||||
<input type="text" name="cc" value="{{.cc}}" placeholder="cc@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题</label>
|
||||
<input type="text" name="subject" value="{{.subject}}" placeholder="邮件主题">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>正文</label>
|
||||
<textarea name="body" rows="12" placeholder="请输入邮件内容..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>附件</label>
|
||||
<input type="file" name="attachments" multiple>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">发送邮件</button>
|
||||
<a href="/inbox" class="btn" style="margin-left:8px;">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,74 @@
|
||||
{{define "drafts"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>草稿箱 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">草稿箱</h2>
|
||||
{{if not .messages}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无草稿邮件</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:25%;">发件人/收件人</th>
|
||||
<th style="width:45%;">主题</th>
|
||||
<th style="width:20%;">时间</th>
|
||||
<th style="width:10%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .messages}}
|
||||
<tr>
|
||||
<td>{{.ToAddr}}</td>
|
||||
<td>
|
||||
<a href="/drafts/{{.ID}}" class="message-subject">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/mail/delete/{{.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/drafts?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/drafts?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,75 @@
|
||||
{{define "inbox"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>收件箱 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">收件箱</h2>
|
||||
{{if not .messages}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:25%;">发件人</th>
|
||||
<th style="width:45%;">主题</th>
|
||||
<th style="width:20%;">时间</th>
|
||||
<th style="width:10%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .messages}}
|
||||
<tr class="{{if not .IsRead}}unread{{end}}">
|
||||
<td>{{.FromAddr}}</td>
|
||||
<td>
|
||||
<a href="/inbox/{{.ID}}" class="message-subject">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
{{if not .IsRead}}
|
||||
<form method="POST" action="/mail/read/{{.ID}}" style="display:inline;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">已读</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/inbox?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/inbox?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "login"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div style="max-width:400px;margin:80px auto;">
|
||||
<div class="card">
|
||||
<h2 style="text-align:center;margin-bottom:24px;">MailGo 登录</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label>邮箱地址</label>
|
||||
<input type="email" name="email" required autofocus placeholder="admin@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" required placeholder="请输入密码">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,74 @@
|
||||
{{define "sent"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>发件箱 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">发件箱</h2>
|
||||
{{if not .messages}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无已发送邮件</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:25%;">收件人</th>
|
||||
<th style="width:45%;">主题</th>
|
||||
<th style="width:20%;">时间</th>
|
||||
<th style="width:10%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .messages}}
|
||||
<tr>
|
||||
<td>{{.ToAddr}}</td>
|
||||
<td>
|
||||
<a href="/sent/{{.ID}}" class="message-subject">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/mail/delete/{{.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
<div class="pagination">
|
||||
{{if gt .page 1}}
|
||||
<a href="/sent?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/sent?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,48 @@
|
||||
{{define "settings"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>设置 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:16px;">设置</h2>
|
||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
|
||||
<h3 style="margin-bottom:12px;">修改密码</h3>
|
||||
<form method="POST" action="/settings">
|
||||
<div class="form-group">
|
||||
<label>当前密码</label>
|
||||
<input type="password" name="old_password" required placeholder="请输入当前密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input type="password" name="new_password" required placeholder="请输入新密码">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" name="confirm_password" required placeholder="请再次输入新密码">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">修改密码</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,64 @@
|
||||
{{define "view"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>查看邮件 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "navbar" .}}
|
||||
<div class="container">
|
||||
<div class="clearfix">
|
||||
<div class="sidebar">
|
||||
<a href="/inbox" class="{{if eq .activeFolder `inbox`}}active{{end}}">收件箱</a>
|
||||
<a href="/drafts" class="{{if eq .activeFolder `drafts`}}active{{end}}">草稿箱</a>
|
||||
<a href="/sent" class="{{if eq .activeFolder `sent`}}active{{end}}">发件箱</a>
|
||||
<a href="/compose" class="{{if eq .activeFolder `compose`}}active{{end}}">撰写邮件</a>
|
||||
<a href="/settings" class="{{if eq .activeFolder `settings`}}active{{end}}">设置</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="margin-bottom:16px;">
|
||||
<a href="javascript:history.back()" class="btn" style="background:#bdc3c7;color:#fff;">返回</a>
|
||||
</div>
|
||||
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h2>
|
||||
<div class="mail-meta" style="margin-top:12px;">
|
||||
<p><strong>发件人:</strong> {{.message.FromAddr}}</p>
|
||||
<p><strong>收件人:</strong> {{.message.ToAddr}}</p>
|
||||
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}}
|
||||
<p><strong>时间:</strong> {{.message.Date.Format "2006-01-02 15:04:05"}}</p>
|
||||
</div>
|
||||
<div class="mail-body">
|
||||
{{if .message.HtmlBody}}
|
||||
{{.message.HtmlBody | safeHTML}}
|
||||
{{else}}
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;">{{.message.TextBody}}</pre>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .attachments}}
|
||||
<div class="attachment-list">
|
||||
<h4 style="margin-bottom:8px;">附件</h4>
|
||||
{{range .attachments}}
|
||||
<div class="attachment-item">
|
||||
📎 <a href="/attachment/{{.ID}}">{{.FileName}}</a>
|
||||
<span style="color:#7f8c8d;font-size:12px;">({{formatBytes .FileSize}})</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #eee;">
|
||||
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="btn btn-danger">删除邮件</button>
|
||||
</form>
|
||||
<a href="/compose?to={{.message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="btn btn-primary" style="margin-left:8px;">回复</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user