一阶段ok

This commit is contained in:
2026-06-01 18:59:55 +08:00
commit 9e50d05e71
52 changed files with 6155 additions and 0 deletions
+54
View File
@@ -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
}
+85
View File
@@ -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"
}
+786
View File
@@ -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()
}
+66
View File
@@ -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)
+465
View File
@@ -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())
}
+300
View File
@@ -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
}
+69
View File
@@ -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)
}
+59
View File
@@ -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
}
+76
View File
@@ -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
}
+125
View File
@@ -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
}
+31
View File
@@ -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{}
+146
View File
@@ -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
}
+435
View File
@@ -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)
}
+76
View File
@@ -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")
}
+521
View File
@@ -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
}
}
+31
View File
@@ -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()
}
}
+55
View File
@@ -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()
}
}
+163
View File
@@ -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}}
+79
View File
@@ -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}}
+81
View File
@@ -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}}
+66
View File
@@ -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}}
+55
View File
@@ -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}}
+74
View File
@@ -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}}
+75
View File
@@ -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}}
+33
View File
@@ -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}}
+74
View File
@@ -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}}
+48
View File
@@ -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}}
+64
View File
@@ -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}}