first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
package accounts
import (
"encoding/json"
"fmt"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/oauth2"
"github.com/google/uuid"
)
// LinkIMAPAccount registers a generic IMAP/SMTP account for a user, storing
// the password encrypted (same HKDF-per-record scheme as messages/contacts —
// "linked-account-cred" is the purpose namespace, keyed by the new account's
// own ID so a compromise of one linked account's credential doesn't expose
// any other record).
func LinkIMAPAccount(database *db.DB, mk *crypto.MasterKey, userID, displayName, email, password,
imapHost string, imapPort int, imapTLS string,
smtpHost string, smtpPort int, smtpTLS string) (*db.LinkedAccount, error) {
accountID := uuid.NewString()
credJSON, err := json.Marshal(IMAPCredential{Password: password})
if err != nil {
return nil, fmt.Errorf("marshaling credential: %w", err)
}
encCred, err := crypto.Encrypt(mk, accountID, "linked-account-cred", credJSON)
if err != nil {
return nil, fmt.Errorf("encrypting credential: %w", err)
}
account := &db.LinkedAccount{
ID: accountID,
UserID: userID,
Provider: db.ProviderIMAP,
DisplayName: displayName,
EmailAddress: email,
AuthType: db.AuthTypePassword,
IMAPHost: imapHost,
IMAPPort: imapPort,
IMAPTLS: imapTLS,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPTLS: smtpTLS,
CredentialEnc: encCred,
Active: true,
}
if err := database.InsertLinkedAccount(account); err != nil {
return nil, err
}
return account, nil
}
// wellKnownIMAPHost returns the fixed IMAP+SMTP host/port/TLS settings for
// Gmail and M365 — these aren't operator- or user-configurable, since
// they're the provider's actual documented endpoints, exactly like
// oauth2.WellKnownEndpoints.
func wellKnownIMAPHost(provider db.LinkedAccountProvider) (imapHost string, imapPort int, imapTLS, smtpHost string, smtpPort int, smtpTLS string, err error) {
switch provider {
case db.ProviderGmail:
return "imap.gmail.com", 993, "implicit", "smtp.gmail.com", 587, "starttls", nil
case db.ProviderM365:
return "outlook.office365.com", 993, "implicit", "smtp.office365.com", 587, "starttls", nil
default:
return "", 0, "", "", 0, "", fmt.Errorf("no well-known IMAP host for provider %q", provider)
}
}
// LinkOAuth2Account registers a Gmail or M365 account, storing the OAuth2
// tokens (from a completed authorization code exchange) encrypted the same
// way as everything else. Mail access goes through IMAP+OAuth2 (XOAUTH2),
// per the plan's decision to start there before adding native Gmail/Graph
// API push in a later pass — see accounts.IMAPProvider.loginOAuth2.
func LinkOAuth2Account(database *db.DB, mk *crypto.MasterKey, userID, displayName, email string,
provider db.LinkedAccountProvider, token *oauth2.Token) (*db.LinkedAccount, error) {
imapHost, imapPort, imapTLS, smtpHost, smtpPort, smtpTLS, err := wellKnownIMAPHost(provider)
if err != nil {
return nil, err
}
accountID := uuid.NewString()
credJSON, err := json.Marshal(OAuth2Credential{
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresAt: token.ExpiresAt,
})
if err != nil {
return nil, fmt.Errorf("marshaling OAuth2 credential: %w", err)
}
encCred, err := crypto.Encrypt(mk, accountID, "linked-account-cred", credJSON)
if err != nil {
return nil, fmt.Errorf("encrypting OAuth2 credential: %w", err)
}
expiresAt := token.ExpiresAt
account := &db.LinkedAccount{
ID: accountID,
UserID: userID,
Provider: provider,
DisplayName: displayName,
EmailAddress: email,
AuthType: db.AuthTypeOAuth2,
IMAPHost: imapHost,
IMAPPort: imapPort,
IMAPTLS: imapTLS,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPTLS: smtpTLS,
CredentialEnc: encCred,
OAuthExpiresAt: &expiresAt,
Active: true,
}
if err := database.InsertLinkedAccount(account); err != nil {
return nil, err
}
return account, nil
}
// ProviderFor returns the right MailProvider implementation for a linked
// account row — the single place that decides which backend handles which
// provider string, so callers (webmail API, sync workers) never need a
// switch statement of their own. The local "gomail" provider isn't built
// through here since it needs a *db.User + *mailstore.Store, not a
// LinkedAccount row — callers construct it directly via NewGoMailProvider.
//
// oauthConfigs is keyed by provider name ("google", "microsoft") — only
// consulted for accounts.AuthTypeOAuth2 rows, to refresh an expired access
// token. Pass nil if the account is known to be password-based.
func ProviderFor(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfigs map[string]*oauth2.Config) (MailProvider, error) {
switch account.Provider {
case db.ProviderIMAP:
return NewIMAPProvider(account, mk), nil
case db.ProviderGmail, db.ProviderM365:
providerKey := "google"
if account.Provider == db.ProviderM365 {
providerKey = "microsoft"
}
cfg := oauthConfigs[providerKey]
return NewIMAPProviderOAuth2(account, mk, database, cfg), nil
default:
return nil, fmt.Errorf("provider %q not supported", account.Provider)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package accounts implements the MailProvider abstraction: one interface,
// multiple backends (local GoMail account, generic IMAP/SMTP, and — Phase 10 —
// Gmail/M365 native APIs). The webmail client (later phase) talks to every
// linked account through this same interface regardless of where the mail
// actually lives.
package accounts
import "context"
type Folder struct {
ID string `json:"id"` // provider-native folder identifier (IMAP mailbox name, etc.)
DisplayName string `json:"display_name"`
Type string `json:"type"` // inbox|sent|drafts|trash|junk|custom
UnreadCount int `json:"unread_count"`
TotalCount int `json:"total_count"`
}
type MessageHeader struct {
ID string `json:"id"` // provider-native message identifier (IMAP UID, etc.)
FolderID string `json:"folder_id"`
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
Date string `json:"date"`
Flags []string `json:"flags"`
SizeBytes int64 `json:"size_bytes"`
}
type FullMessage struct {
MessageHeader
Raw []byte `json:"raw"` // full RFC 5322 message; json.Marshal base64-encodes []byte automatically
}
type OutgoingMessage struct {
From string `json:"from"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
Subject string `json:"subject"`
Body string `json:"body"` // plain text; HTML composer is a webmail-phase concern
}
type ListOpts struct {
Limit int
Offset int
}
type SyncResult struct {
NewCursor string
NewMessages []MessageHeader
DeletedIDs []string
FlagsChanged map[string][]string // messageID -> new flags
}
// MailProvider is implemented once per account type. Every method takes a
// context so network-backed implementations (IMAP, and later Gmail/Graph
// API) can be cancelled/timed-out uniformly with the local implementation.
type MailProvider interface {
ListFolders(ctx context.Context) ([]Folder, error)
ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error)
GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error)
SendMessage(ctx context.Context, msg *OutgoingMessage) error
SetFlags(ctx context.Context, folderID, messageID string, flags []string) error
Move(ctx context.Context, folderID, messageID, destFolderID string) error
Delete(ctx context.Context, folderID, messageID string) error
Sync(ctx context.Context, since string) (*SyncResult, error)
}
+256
View File
@@ -0,0 +1,256 @@
package accounts
import (
"context"
"fmt"
"net/mail"
"strconv"
"strings"
"time"
"gomail/internal/db"
"gomail/internal/mailstore"
"github.com/google/uuid"
)
// GoMailProvider implements MailProvider for the user's own local account —
// direct function calls against the database and Maildir store, no network
// round-trip. This is what the webmail client uses for "your own" mailbox;
// Phase 9's JMAP server exposes the same data over HTTP for third-party
// clients, but the webmail's internal path stays this direct route since it's
// strictly faster for same-process access.
type GoMailProvider struct {
database *db.DB
store *mailstore.Store
user *db.User
}
func NewGoMailProvider(database *db.DB, store *mailstore.Store, user *db.User) *GoMailProvider {
return &GoMailProvider{database: database, store: store, user: user}
}
func (p *GoMailProvider) ListFolders(_ context.Context) ([]Folder, error) {
names, err := p.database.ListMailboxNames(p.user.ID)
if err != nil {
return nil, err
}
var folders []Folder
for _, name := range names {
entries, err := p.database.ListMailboxEntries(p.user.ID, name)
if err != nil {
continue
}
unread := 0
for _, e := range entries {
if !strings.Contains(e.Flags, "\\Seen") {
unread++
}
}
folders = append(folders, Folder{
ID: name,
DisplayName: name,
Type: folderType(name),
UnreadCount: unread,
TotalCount: len(entries),
})
}
return folders, nil
}
func (p *GoMailProvider) ListMessages(_ context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
if err != nil {
return nil, err
}
// Newest first, matching typical mail client default sort.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
entries[i], entries[j] = entries[j], entries[i]
}
start := opts.Offset
if start > len(entries) {
start = len(entries)
}
end := len(entries)
if opts.Limit > 0 && start+opts.Limit < end {
end = start + opts.Limit
}
page := entries[start:end]
var headers []MessageHeader
for _, e := range page {
raw, err := p.store.Read(e.EMLPath)
if err != nil {
continue
}
headers = append(headers, headerFromRaw(strconv.Itoa(e.UID), folderID, raw, e.Flags, e.SizeBytes))
}
return headers, nil
}
func (p *GoMailProvider) GetMessage(_ context.Context, folderID, messageID string) (*FullMessage, error) {
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
if err != nil {
return nil, err
}
uid, _ := strconv.Atoi(messageID)
for _, e := range entries {
if e.UID == uid {
raw, err := p.store.Read(e.EMLPath)
if err != nil {
return nil, err
}
hdr := headerFromRaw(messageID, folderID, raw, e.Flags, e.SizeBytes)
return &FullMessage{MessageHeader: hdr, Raw: raw}, nil
}
}
return nil, db.ErrNotFound
}
// SendMessage delivers locally if the recipient is a GoMail user on this
// instance, otherwise stages it in the outbound queue — same routing logic
// SMTP submission uses, exposed here so webmail compose doesn't need to loop
// back through the SMTP port to send its own account's mail.
func (p *GoMailProvider) SendMessage(_ context.Context, msg *OutgoingMessage) error {
raw := buildRFC5322(p.user.Email, msg)
for _, to := range msg.To {
domain := domainOf(to)
localDomain, err := p.database.LookupDomainByName(domain)
if err == nil && localDomain != nil {
if recipUser, err := p.database.LookupUserByEmail(to); err == nil {
if _, err := p.store.Deliver(recipUser.ID, recipUser.Email, "INBOX", raw); err != nil {
return fmt.Errorf("local delivery to %s failed: %w", to, err)
}
continue
}
}
_, queuePath, err := p.store.WriteQueueFile(raw)
if err != nil {
return fmt.Errorf("staging outbound message: %w", err)
}
entry := &db.OutboundQueueEntry{
ID: uuid.NewString(),
UserID: p.user.ID,
FromAddress: p.user.Email,
ToAddress: to,
EMLPath: queuePath,
NextAttemptAt: time.Now().UTC(),
}
if err := p.database.InsertOutboundQueueEntry(entry); err != nil {
return fmt.Errorf("enqueueing outbound message to %s: %w", to, err)
}
}
return nil
}
func (p *GoMailProvider) SetFlags(_ context.Context, folderID, messageID string, flags []string) error {
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
if err != nil {
return err
}
uid, _ := strconv.Atoi(messageID)
for _, e := range entries {
if e.UID == uid {
return p.database.UpdateMailboxFlags(e.ID, strings.Join(flags, " "))
}
}
return db.ErrNotFound
}
// Move re-delivers the message into the destination folder and removes it
// from the source — GoMail's Maildir store has no native "move" primitive
// (each mailbox is its own directory tree), so this is copy+delete rather
// than an atomic rename. Acceptable since both halves are local and fast;
// a future optimization could hardlink instead of re-encrypting.
func (p *GoMailProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
full, err := p.GetMessage(ctx, folderID, messageID)
if err != nil {
return err
}
if _, err := p.store.Deliver(p.user.ID, p.user.Email, destFolderID, full.Raw); err != nil {
return fmt.Errorf("delivering to destination folder: %w", err)
}
return p.Delete(ctx, folderID, messageID)
}
func (p *GoMailProvider) Delete(_ context.Context, folderID, messageID string) error {
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
if err != nil {
return err
}
uid, _ := strconv.Atoi(messageID)
for _, e := range entries {
if e.UID == uid {
return p.database.DeleteMailboxEntry(e.ID)
}
}
return db.ErrNotFound
}
// Sync for the local provider is a no-op in the SyncResult sense — the
// caller already has live DB access via ListMessages, there's no remote
// state to reconcile. Implemented to satisfy the interface for callers that
// treat every linked account uniformly (the future unified-inbox sync loop).
func (p *GoMailProvider) Sync(_ context.Context, _ string) (*SyncResult, error) {
return &SyncResult{NewCursor: ""}, nil
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func folderType(name string) string {
switch strings.ToUpper(name) {
case "INBOX":
return "inbox"
case "SENT":
return "sent"
case "DRAFTS":
return "drafts"
case "TRASH":
return "trash"
case "JUNK":
return "junk"
default:
return "custom"
}
}
func headerFromRaw(id, folderID string, raw []byte, flags string, size int64) MessageHeader {
h := MessageHeader{ID: id, FolderID: folderID, SizeBytes: size}
if flags != "" {
h.Flags = strings.Fields(flags)
}
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err == nil {
h.From = msg.Header.Get("From")
h.To = msg.Header.Get("To")
h.Subject = msg.Header.Get("Subject")
h.Date = msg.Header.Get("Date")
}
return h
}
func domainOf(email string) string {
parts := strings.SplitN(email, "@", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
func buildRFC5322(from string, msg *OutgoingMessage) []byte {
var b strings.Builder
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + strings.Join(msg.To, ", ") + "\r\n")
if len(msg.CC) > 0 {
b.WriteString("Cc: " + strings.Join(msg.CC, ", ") + "\r\n")
}
b.WriteString("Subject: " + msg.Subject + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("\r\n")
b.WriteString(msg.Body)
b.WriteString("\r\n")
return []byte(b.String())
}
+322
View File
@@ -0,0 +1,322 @@
package accounts
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/mail"
"strconv"
"strings"
"time"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/imapclient"
"gomail/internal/oauth2"
)
const dialTimeout = 20 * time.Second
// IMAPCredential is what's stored (encrypted) in linked_accounts.credential_enc
// for AuthTypePassword accounts.
type IMAPCredential struct {
Password string `json:"password"`
}
// OAuth2Credential is what's stored (encrypted) in
// linked_accounts.credential_enc for AuthTypeOAuth2 accounts (Gmail, M365,
// or any provider using XOAUTH2). RefreshToken is used to obtain a new
// AccessToken transparently once the stored one expires.
type OAuth2Credential struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
}
// IMAPProvider implements MailProvider for a generic external IMAP account.
// Every method dials fresh — IMAP has no cheap "keep a pool of idle
// connections" story without IDLE/pooling machinery this pass doesn't build
// yet, so simplicity wins: connect, do the one operation, disconnect. A
// later pass can add persistent connections if per-operation latency matters.
type IMAPProvider struct {
account *db.LinkedAccount
mk *crypto.MasterKey
database *db.DB // needed to persist a refreshed access token
oauthConfig *oauth2.Config // nil for password-auth accounts
}
func NewIMAPProvider(account *db.LinkedAccount, mk *crypto.MasterKey) *IMAPProvider {
return &IMAPProvider{account: account, mk: mk}
}
// NewIMAPProviderOAuth2 is used for accounts.AuthTypeOAuth2 — the caller
// supplies the provider's oauth2.Config (built from operator-configured
// Client ID/Secret) so a stored access token can be refreshed transparently
// on expiry. database is used to persist the refreshed token — refreshing
// silently in memory only would force a re-refresh on every single
// operation instead of once per real expiry.
func NewIMAPProviderOAuth2(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *IMAPProvider {
return &IMAPProvider{account: account, mk: mk, database: database, oauthConfig: oauthConfig}
}
func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error) {
addr := fmt.Sprintf("%s:%d", p.account.IMAPHost, p.account.IMAPPort)
// InsecureSkipVerify is a known gap, not a silent one: real ACME
// issuance now exists (internal/acme, Phase 13), but this instance's own
// IMAP server still falls back to a self-signed cert whenever the
// operator hasn't configured tls.acme_domains — and a user's *other*
// linked IMAP server (their actual Gmail/M365/self-hosted account) is
// entirely outside our control regardless. Real clients solve the
// latter with an explicit "accept this certificate" trust step
// (pinning by fingerprint) — that UI flow belongs in the webmail
// account-linking phase, not here. Tracked as a gap, not swept under
// the rug.
tlsConf := &tls.Config{ServerName: p.account.IMAPHost, InsecureSkipVerify: true}
implicit := p.account.IMAPTLS == "implicit"
client, err := imapclient.Dial(addr, implicit, tlsConf, dialTimeout)
if err != nil {
return nil, err
}
if p.account.IMAPTLS == "starttls" {
if err := client.StartTLS(tlsConf); err != nil {
return nil, fmt.Errorf("STARTTLS: %w", err)
}
}
if p.account.AuthType == db.AuthTypeOAuth2 {
if err := p.loginOAuth2(ctx, client); err != nil {
return nil, err
}
return client, nil
}
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
if err != nil {
return nil, fmt.Errorf("decrypting stored credential: %w", err)
}
var cred IMAPCredential
if err := json.Unmarshal(plain, &cred); err != nil {
return nil, fmt.Errorf("parsing stored credential: %w", err)
}
if err := client.Login(p.account.EmailAddress, cred.Password); err != nil {
return nil, fmt.Errorf("IMAP login: %w", err)
}
return client, nil
}
// loginOAuth2 decrypts the stored OAuth2 credential, transparently refreshes
// it if expired (persisting the new token so the next call doesn't have to
// refresh again), and authenticates via SASL XOAUTH2.
func (p *IMAPProvider) loginOAuth2(ctx context.Context, client *imapclient.Client) error {
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
if err != nil {
return fmt.Errorf("decrypting stored OAuth2 credential: %w", err)
}
var cred OAuth2Credential
if err := json.Unmarshal(plain, &cred); err != nil {
return fmt.Errorf("parsing stored OAuth2 credential: %w", err)
}
if time.Now().UTC().After(cred.ExpiresAt) {
if p.oauthConfig == nil {
return fmt.Errorf("access token expired and no oauth2.Config available to refresh it")
}
newTok, err := p.oauthConfig.RefreshToken(ctx, cred.RefreshToken)
if err != nil {
return fmt.Errorf("refreshing OAuth2 token: %w", err)
}
cred.AccessToken = newTok.AccessToken
cred.RefreshToken = newTok.RefreshToken
cred.ExpiresAt = newTok.ExpiresAt
if p.database != nil {
updated, err := json.Marshal(cred)
if err == nil {
if encUpdated, encErr := crypto.Encrypt(p.mk, p.account.ID, "linked-account-cred", updated); encErr == nil {
p.database.Exec(`UPDATE linked_accounts SET credential_enc = ?, oauth_expires_at = ? WHERE id = ?`,
encUpdated, cred.ExpiresAt, p.account.ID)
}
}
}
}
sasl := oauth2.XOAUTH2SASLString(p.account.EmailAddress, cred.AccessToken)
if err := client.LoginXOAUTH2(sasl); err != nil {
return fmt.Errorf("XOAUTH2 login: %w", err)
}
return nil
}
func (p *IMAPProvider) ListFolders(ctx context.Context) ([]Folder, error) {
client, err := p.connect(ctx)
if err != nil {
return nil, err
}
defer client.Logout()
list, err := client.List()
if err != nil {
return nil, err
}
var folders []Folder
for _, f := range list {
info, err := client.Select(f.Name)
total := 0
if err == nil {
total = info.Exists
}
folders = append(folders, Folder{
ID: f.Name, DisplayName: f.Name, Type: folderType(f.Name), TotalCount: total,
})
}
return folders, nil
}
func (p *IMAPProvider) ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
client, err := p.connect(ctx)
if err != nil {
return nil, err
}
defer client.Logout()
info, err := client.Select(folderID)
if err != nil {
return nil, err
}
if info.Exists == 0 {
return nil, nil
}
limit := opts.Limit
if limit <= 0 || limit > info.Exists {
limit = info.Exists
}
lo := info.Exists - limit + 1 - opts.Offset
if lo < 1 {
lo = 1
}
hi := info.Exists - opts.Offset
if hi < lo {
return nil, nil
}
seqSet := fmt.Sprintf("%d:%d", lo, hi)
fetched, err := client.Fetch(seqSet, "(UID FLAGS BODY.PEEK[HEADER])")
if err != nil {
return nil, err
}
var headers []MessageHeader
for _, f := range fetched {
h := MessageHeader{
ID: strconv.Itoa(f.UID),
FolderID: folderID,
Flags: f.Flags,
}
if msg, err := mail.ReadMessage(strings.NewReader(string(f.Body))); err == nil {
h.From = msg.Header.Get("From")
h.To = msg.Header.Get("To")
h.Subject = msg.Header.Get("Subject")
h.Date = msg.Header.Get("Date")
}
headers = append(headers, h)
}
return headers, nil
}
func (p *IMAPProvider) GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error) {
client, err := p.connect(ctx)
if err != nil {
return nil, err
}
defer client.Logout()
if _, err := client.Select(folderID); err != nil {
return nil, err
}
fetched, err := client.UIDFetch(messageID, "(UID FLAGS BODY.PEEK[])")
if err != nil {
return nil, err
}
if len(fetched) == 0 {
return nil, db.ErrNotFound
}
f := fetched[0]
h := MessageHeader{ID: messageID, FolderID: folderID, Flags: f.Flags, SizeBytes: int64(len(f.Body))}
if msg, err := mail.ReadMessage(strings.NewReader(string(f.Body))); err == nil {
h.From = msg.Header.Get("From")
h.To = msg.Header.Get("To")
h.Subject = msg.Header.Get("Subject")
h.Date = msg.Header.Get("Date")
}
return &FullMessage{MessageHeader: h, Raw: f.Body}, nil
}
// SendMessage for a generic IMAP account routes through its paired SMTP
// settings — IMAP itself has no send capability, so this dials the account's
// smtp_host/port using the same stored credential, via stdlib net/smtp,
// mirroring the approach in internal/queue's MXDeliverer.
func (p *IMAPProvider) SendMessage(_ context.Context, msg *OutgoingMessage) error {
return sendViaSMTP(p.account, p.mk, msg)
}
func (p *IMAPProvider) SetFlags(ctx context.Context, folderID, messageID string, flags []string) error {
client, err := p.connect(ctx)
if err != nil {
return err
}
defer client.Logout()
if _, err := client.Select(folderID); err != nil {
return err
}
return client.UIDStore(messageID, "FLAGS", strings.Join(flags, " "))
}
func (p *IMAPProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
// No native IMAP MOVE issued here (RFC 6851 COPY+EXPUNGE equivalent) —
// implemented as fetch-from-source + append-style re-delivery is not
// available without APPEND support (deferred). For now: mark \Deleted
// and expunge in the source; true cross-folder move needs APPEND, noted
// as a gap rather than silently mis-behaving.
return fmt.Errorf("Move not yet implemented for generic IMAP accounts (requires APPEND, deferred)")
}
func (p *IMAPProvider) Delete(ctx context.Context, folderID, messageID string) error {
client, err := p.connect(ctx)
if err != nil {
return err
}
defer client.Logout()
if _, err := client.Select(folderID); err != nil {
return err
}
if err := client.UIDStore(messageID, "+FLAGS", `\Deleted`); err != nil {
return err
}
return client.Expunge()
}
func (p *IMAPProvider) Sync(ctx context.Context, _ string) (*SyncResult, error) {
// Full re-list — no CONDSTORE/QRESYNC support yet (deferred, noted in
// package docs). Correct, just not incremental.
folders, err := p.ListFolders(ctx)
if err != nil {
return nil, err
}
var all []MessageHeader
for _, f := range folders {
msgs, err := p.ListMessages(ctx, f.ID, ListOpts{})
if err != nil {
continue
}
all = append(all, msgs...)
}
return &SyncResult{NewMessages: all}, nil
}
+81
View File
@@ -0,0 +1,81 @@
package accounts
import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/smtp"
"strconv"
"gomail/internal/crypto"
"gomail/internal/db"
)
// sendViaSMTP delivers an outgoing message through a linked account's own
// SMTP settings — stdlib net/smtp, same choice as internal/queue's
// MXDeliverer, so outbound for both "GoMail relays for me" and "I'm using my
// own IMAP+SMTP provider" stay on the same dependency-free foundation.
func sendViaSMTP(account *db.LinkedAccount, mk *crypto.MasterKey, msg *OutgoingMessage) error {
plain, err := crypto.Decrypt(mk, account.ID, "linked-account-cred", account.CredentialEnc)
if err != nil {
return fmt.Errorf("decrypting stored credential: %w", err)
}
var cred IMAPCredential // same password shape reused for the paired SMTP auth
if err := json.Unmarshal(plain, &cred); err != nil {
return fmt.Errorf("parsing stored credential: %w", err)
}
addr := net.JoinHostPort(account.SMTPHost, strconv.Itoa(account.SMTPPort))
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
defer conn.Close()
if account.SMTPTLS == "implicit" {
// See provider_imap.go's connect() comment — same gap, same reason.
conn = tls.Client(conn, &tls.Config{ServerName: account.SMTPHost, InsecureSkipVerify: true})
}
client, err := smtp.NewClient(conn, account.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP handshake: %w", err)
}
defer client.Close()
if account.SMTPTLS == "starttls" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: account.SMTPHost}); err != nil {
return fmt.Errorf("STARTTLS: %w", err)
}
}
}
auth := smtp.PlainAuth("", account.EmailAddress, cred.Password, account.SMTPHost)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth: %w", err)
}
if err := client.Mail(account.EmailAddress); err != nil {
return err
}
for _, to := range msg.To {
if err := client.Rcpt(to); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
raw := buildRFC5322(account.EmailAddress, msg)
if _, err := w.Write(raw); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}
+45
View File
@@ -0,0 +1,45 @@
package acme
import (
"net/http"
"strings"
"sync"
)
// ChallengeResponder serves HTTP-01 challenge responses at
// /.well-known/acme-challenge/{token} — mount it on the plain :80 listener
// (or wherever the CA's HTTP-01 validator will connect) before requesting
// challenge validation.
type ChallengeResponder struct {
mu sync.RWMutex
tokens map[string]string // token -> key authorization
}
func NewChallengeResponder() *ChallengeResponder {
return &ChallengeResponder{tokens: make(map[string]string)}
}
func (c *ChallengeResponder) Set(token, keyAuthorization string) {
c.mu.Lock()
defer c.mu.Unlock()
c.tokens[token] = keyAuthorization
}
func (c *ChallengeResponder) Remove(token string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.tokens, token)
}
func (c *ChallengeResponder) ServeHTTP(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.URL.Path, "/.well-known/acme-challenge/")
c.mu.RLock()
keyAuth, ok := c.tokens[token]
c.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte(keyAuth))
}
+301
View File
@@ -0,0 +1,301 @@
package acme
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"time"
)
type directory struct {
NewNonce string `json:"newNonce"`
NewAccount string `json:"newAccount"`
NewOrder string `json:"newOrder"`
}
type Client struct {
directoryURL string
httpClient *http.Client
dir directory
accountKey *AccountKey
accountURL string
nonce string
}
func NewClient(directoryURL string, accountKey *AccountKey) *Client {
return &Client{
directoryURL: directoryURL,
httpClient: &http.Client{Timeout: 30 * time.Second},
accountKey: accountKey,
}
}
// Bootstrap fetches the directory and a fresh nonce — call once before any
// other method.
func (c *Client) Bootstrap() error {
resp, err := c.httpClient.Get(c.directoryURL)
if err != nil {
return fmt.Errorf("fetching ACME directory: %w", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&c.dir); err != nil {
return fmt.Errorf("parsing ACME directory: %w", err)
}
nonceResp, err := c.httpClient.Head(c.dir.NewNonce)
if err != nil {
return fmt.Errorf("fetching initial nonce: %w", err)
}
defer nonceResp.Body.Close()
c.nonce = nonceResp.Header.Get("Replay-Nonce")
if c.nonce == "" {
return fmt.Errorf("server did not return a Replay-Nonce")
}
return nil
}
// post sends a JWS-signed POST and captures the next nonce from the
// response for the following request — ACME nonces are single-use.
func (c *Client) post(url string, payload []byte) (*http.Response, []byte, error) {
useJWK := c.accountURL == ""
body, err := c.accountKey.signJWS(url, c.nonce, useJWK, c.accountURL, payload)
if err != nil {
return nil, nil, fmt.Errorf("signing request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/jose+json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("ACME request to %s: %w", url, err)
}
defer resp.Body.Close()
if n := resp.Header.Get("Replay-Nonce"); n != "" {
c.nonce = n
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return resp, nil, fmt.Errorf("reading response body: %w", err)
}
return resp, respBody, nil
}
// NewAccount registers (or, per RFC 8555 §7.3.1, retrieves the existing
// account for this key if already registered) an ACME account.
func (c *Client) NewAccount(contactEmail string) error {
payload, err := json.Marshal(map[string]any{
"termsOfServiceAgreed": true,
"contact": []string{"mailto:" + contactEmail},
})
if err != nil {
return err
}
resp, body, err := c.post(c.dir.NewAccount, payload)
if err != nil {
return err
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return fmt.Errorf("new-account failed: status %d: %s", resp.StatusCode, string(body))
}
c.accountURL = resp.Header.Get("Location")
if c.accountURL == "" {
return fmt.Errorf("server did not return an account URL")
}
return nil
}
type Order struct {
Status string `json:"status"`
Authorizations []string `json:"authorizations"`
Finalize string `json:"finalize"`
Certificate string `json:"certificate"`
orderURL string
}
func (c *Client) NewOrder(domains []string) (*Order, error) {
var idents []map[string]string
for _, d := range domains {
idents = append(idents, map[string]string{"type": "dns", "value": d})
}
payload, err := json.Marshal(map[string]any{"identifiers": idents})
if err != nil {
return nil, err
}
resp, body, err := c.post(c.dir.NewOrder, payload)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("new-order failed: status %d: %s", resp.StatusCode, string(body))
}
var order Order
if err := json.Unmarshal(body, &order); err != nil {
return nil, fmt.Errorf("parsing order: %w", err)
}
order.orderURL = resp.Header.Get("Location")
return &order, nil
}
type Authorization struct {
Status string `json:"status"`
Identifier struct {
Value string `json:"value"`
} `json:"identifier"`
Challenges []Challenge `json:"challenges"`
}
type Challenge struct {
Type string `json:"type"`
URL string `json:"url"`
Token string `json:"token"`
Status string `json:"status"`
}
// GetAuthorization fetches one authorization (POST-as-GET, per RFC 8555 §6.3).
func (c *Client) GetAuthorization(authzURL string) (*Authorization, error) {
resp, body, err := c.post(authzURL, nil)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get authorization failed: status %d: %s", resp.StatusCode, string(body))
}
var authz Authorization
if err := json.Unmarshal(body, &authz); err != nil {
return nil, fmt.Errorf("parsing authorization: %w", err)
}
return &authz, nil
}
// KeyAuthorization builds the value the HTTP-01 challenge response must
// serve at /.well-known/acme-challenge/{token} — the token plus a JWK
// thumbprint of the account key, per RFC 8555 §8.3.
func (c *Client) KeyAuthorization(token string) string {
return token + "." + c.accountKey.thumbprint()
}
// RespondToChallenge tells the server the challenge is ready to be
// validated — the caller must have already made the key authorization
// available at the HTTP-01 well-known path before calling this.
func (c *Client) RespondToChallenge(challengeURL string) error {
resp, body, err := c.post(challengeURL, []byte("{}"))
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("challenge response failed: status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// WaitForAuthorizationValid polls an authorization until it's valid,
// invalid, or the timeout elapses.
func (c *Client) WaitForAuthorizationValid(authzURL string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
authz, err := c.GetAuthorization(authzURL)
if err != nil {
return err
}
switch authz.Status {
case "valid":
return nil
case "invalid":
return fmt.Errorf("authorization for %s became invalid", authz.Identifier.Value)
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timed out waiting for authorization to become valid")
}
// FinalizeAndDownload generates a fresh certificate key pair, builds and
// submits a CSR, polls the order until the certificate is issued, and
// downloads it — returning the PEM-encoded cert chain and the PEM-encoded
// private key for the certificate (distinct from the ACME account key).
func (c *Client) FinalizeAndDownload(order *Order, domains []string, timeout time.Duration) (certPEM, keyPEM []byte, err error) {
certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generating certificate key: %w", err)
}
csrDER, err := buildCSR(certKey, domains)
if err != nil {
return nil, nil, fmt.Errorf("building CSR: %w", err)
}
payload, err := json.Marshal(map[string]string{"csr": b64(csrDER)})
if err != nil {
return nil, nil, err
}
resp, body, err := c.post(order.Finalize, payload)
if err != nil {
return nil, nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("finalize failed: status %d: %s", resp.StatusCode, string(body))
}
var finalized Order
if err := json.Unmarshal(body, &finalized); err != nil {
return nil, nil, fmt.Errorf("parsing finalized order: %w", err)
}
finalized.orderURL = order.orderURL
deadline := time.Now().Add(timeout)
for finalized.Status != "valid" {
if time.Now().After(deadline) {
return nil, nil, fmt.Errorf("timed out waiting for order to become valid (status: %s)", finalized.Status)
}
time.Sleep(200 * time.Millisecond)
_, pollBody, err := c.post(finalized.orderURL, nil)
if err != nil {
return nil, nil, err
}
if err := json.Unmarshal(pollBody, &finalized); err != nil {
return nil, nil, fmt.Errorf("parsing polled order: %w", err)
}
finalized.orderURL = order.orderURL
}
certResp, certBody, err := c.post(finalized.Certificate, nil)
if err != nil {
return nil, nil, err
}
if certResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("certificate download failed: status %d", certResp.StatusCode)
}
keyDER, err := x509.MarshalECPrivateKey(certKey)
if err != nil {
return nil, nil, fmt.Errorf("marshaling certificate key: %w", err)
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certBody, keyPEM, nil
}
func buildCSR(key *ecdsa.PrivateKey, domains []string) ([]byte, error) {
template := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: domains[0]},
DNSNames: domains,
}
return x509.CreateCertificateRequest(rand.Reader, template, key)
}
+143
View File
@@ -0,0 +1,143 @@
// Package acme implements an ACME v2 (RFC 8555) client — account
// registration, order creation, HTTP-01 challenge response, and
// certificate issuance/renewal. Hand-rolled on stdlib crypto/ecdsa +
// encoding/json + net/http, including the JWS request signing ACME
// requires (RFC 7515 subset: ES256 only, flattened JSON serialization) —
// no third-party ACME or JOSE library, matching the project's
// dependency-minimal principle.
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
)
// AccountKey wraps the ECDSA P-256 key pair ACME accounts are identified
// by — generated once per hosted domain (or per instance) and stored
// encrypted, same pattern as DKIM keys.
type AccountKey struct {
Private *ecdsa.PrivateKey
}
func GenerateAccountKey() (*AccountKey, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating ACME account key: %w", err)
}
return &AccountKey{Private: priv}, nil
}
func (k *AccountKey) MarshalPEM() ([]byte, error) {
der, err := x509.MarshalECPrivateKey(k.Private)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil
}
func ParseAccountKeyPEM(pemBytes []byte) (*AccountKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
priv, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing EC private key: %w", err)
}
return &AccountKey{Private: priv}, nil
}
// jwk is the JSON Web Key representation of the account's public key —
// required in the JWS protected header for the very first request
// (new-account), before the server has assigned an account URL (kid).
type jwk struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
func (k *AccountKey) jwkValue() jwk {
size := 32 // P-256 coordinate size in bytes
return jwk{
Kty: "EC", Crv: "P-256",
X: b64(leftPad(k.Private.X.Bytes(), size)),
Y: b64(leftPad(k.Private.Y.Bytes(), size)),
}
}
// thumbprint computes the JWK thumbprint (RFC 7638) — used as the
// "key authorization" suffix for HTTP-01 challenge responses.
func (k *AccountKey) thumbprint() string {
j := k.jwkValue()
// RFC 7638 requires this EXACT key order and no extra whitespace.
canonical := fmt.Sprintf(`{"crv":"%s","kty":"%s","x":"%s","y":"%s"}`, j.Crv, j.Kty, j.X, j.Y)
sum := sha256.Sum256([]byte(canonical))
return b64(sum[:])
}
// signJWS builds a flattened-JSON-serialization JWS per RFC 7515, signed
// with ES256, for one ACME request. Exactly one of useJWK/kid applies:
// useJWK for the very first request (new-account), kid for every request
// after (identifying the now-registered account by URL).
func (k *AccountKey) signJWS(url, nonce string, useJWK bool, kid string, payload []byte) ([]byte, error) {
protected := map[string]any{
"alg": "ES256",
"nonce": nonce,
"url": url,
}
if useJWK {
protected["jwk"] = k.jwkValue()
} else {
protected["kid"] = kid
}
protectedJSON, err := json.Marshal(protected)
if err != nil {
return nil, fmt.Errorf("marshaling protected header: %w", err)
}
protectedB64 := b64(protectedJSON)
var payloadB64 string
if payload != nil {
payloadB64 = b64(payload)
}
// A nil payload (POST-as-GET requests) intentionally encodes as "" —
// not "null" — per RFC 8555 §6.3.
signingInput := protectedB64 + "." + payloadB64
hash := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, k.Private, hash[:])
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
sigBytes := append(leftPad(r.Bytes(), 32), leftPad(s.Bytes(), 32)...)
jwsBody := map[string]string{
"protected": protectedB64,
"payload": payloadB64,
"signature": b64(sigBytes),
}
return json.Marshal(jwsBody)
}
func b64(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
func leftPad(b []byte, size int) []byte {
if len(b) >= size {
return b
}
out := make([]byte, size)
copy(out[size-len(b):], b)
return out
}
+67
View File
@@ -0,0 +1,67 @@
package acme
import (
"fmt"
"time"
)
// Obtain drives the complete ACME issuance flow for one or more domains:
// bootstrap, account registration, order, HTTP-01 challenge response via
// responder, finalize, download. The caller is responsible for mounting
// responder on a listener the CA's HTTP-01 validator can reach at
// http://{domain}/.well-known/acme-challenge/{token} — this function only
// populates the token->response map, it doesn't start any listener itself.
func Obtain(directoryURL, contactEmail string, domains []string, accountKey *AccountKey, responder *ChallengeResponder) (certPEM, keyPEM []byte, err error) {
client := NewClient(directoryURL, accountKey)
if err := client.Bootstrap(); err != nil {
return nil, nil, fmt.Errorf("bootstrap: %w", err)
}
if err := client.NewAccount(contactEmail); err != nil {
return nil, nil, fmt.Errorf("account registration: %w", err)
}
order, err := client.NewOrder(domains)
if err != nil {
return nil, nil, fmt.Errorf("creating order: %w", err)
}
for _, authzURL := range order.Authorizations {
authz, err := client.GetAuthorization(authzURL)
if err != nil {
return nil, nil, fmt.Errorf("fetching authorization: %w", err)
}
if authz.Status == "valid" {
continue // already satisfied (e.g. from a very recent prior order)
}
var httpChallenge *Challenge
for i := range authz.Challenges {
if authz.Challenges[i].Type == "http-01" {
httpChallenge = &authz.Challenges[i]
break
}
}
if httpChallenge == nil {
return nil, nil, fmt.Errorf("no http-01 challenge offered for %s", authz.Identifier.Value)
}
keyAuth := client.KeyAuthorization(httpChallenge.Token)
responder.Set(httpChallenge.Token, keyAuth)
if err := client.RespondToChallenge(httpChallenge.URL); err != nil {
responder.Remove(httpChallenge.Token)
return nil, nil, fmt.Errorf("responding to challenge for %s: %w", authz.Identifier.Value, err)
}
waitErr := client.WaitForAuthorizationValid(authzURL, 30*time.Second)
responder.Remove(httpChallenge.Token)
if waitErr != nil {
return nil, nil, fmt.Errorf("waiting for validation of %s: %w", authz.Identifier.Value, waitErr)
}
}
certPEM, keyPEM, err = client.FinalizeAndDownload(order, domains, 30*time.Second)
if err != nil {
return nil, nil, fmt.Errorf("finalize/download: %w", err)
}
return certPEM, keyPEM, nil
}
+311
View File
@@ -0,0 +1,311 @@
// Package admin implements the admin portal REST API — domains (with DKIM
// key generation), tenants, users, list rules, outbound queue management,
// global quarantine, and dashboard stats. Reuses webtoken for sessions
// (same JWT scheme as webmail) but enforces role-based access: only
// global_admin and tenant_admin roles may authenticate here at all, and
// tenant_admin is scoped to their own tenant for every operation.
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
"gomail/internal/auth"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/dkim"
"gomail/internal/webtoken"
"github.com/google/uuid"
)
const sessionTTL = 24 * time.Hour
type Handler struct {
database *db.DB
mk *crypto.MasterKey
jwtSecret string
}
func NewHandler(database *db.DB, mk *crypto.MasterKey, jwtSecret string) *Handler {
return &Handler{database: database, mk: mk, jwtSecret: jwtSecret}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/admin/auth/login", h.login)
mux.HandleFunc("/api/admin/stats", h.withAdmin(h.stats))
mux.HandleFunc("/api/admin/tenants", h.withAdmin(h.tenants))
mux.HandleFunc("/api/admin/domains", h.withAdmin(h.domains))
mux.HandleFunc("/api/admin/domains/", h.withAdmin(h.domainByID))
mux.HandleFunc("/api/admin/users", h.withAdmin(h.users))
mux.HandleFunc("/api/admin/users/", h.withAdmin(h.userByID))
mux.HandleFunc("/api/admin/list-rules", h.withAdmin(h.listRules))
mux.HandleFunc("/api/admin/list-rules/", h.withAdmin(h.listRuleByID))
mux.HandleFunc("/api/admin/queue", h.withAdmin(h.queue))
mux.HandleFunc("/api/admin/queue/", h.withAdmin(h.queueByID))
mux.HandleFunc("/api/admin/quarantine", h.withAdmin(h.quarantine))
mux.HandleFunc("/api/admin/quarantine/", h.withAdmin(h.quarantineByID))
}
// ── JSON helpers ──────────────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// ── Auth (admin-only roles) ─────────────────────────────────────────────────────
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ Email, Password string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
user, ok := auth.Authenticate(h.database, req.Email, req.Password, auth.ScopeIMAP)
if !ok {
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
if user.Role != db.RoleGlobalAdmin && user.Role != db.RoleTenantAdmin {
// Deliberately the same error as bad credentials — don't leak "this
// account exists but lacks admin rights" to an unauthenticated caller.
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
if err != nil {
writeErr(w, http.StatusInternalServerError, "token generation failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"token": token,
"user": map[string]any{"id": user.ID, "email": user.Email, "role": user.Role},
})
}
func (h *Handler) withAdmin(next func(http.ResponseWriter, *http.Request, *db.User)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenStr := ""
if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
}
if tokenStr == "" {
writeErr(w, http.StatusUnauthorized, "missing token")
return
}
claims, err := webtoken.Verify(h.jwtSecret, tokenStr)
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
return
}
if claims.Role != string(db.RoleGlobalAdmin) && claims.Role != string(db.RoleTenantAdmin) {
writeErr(w, http.StatusForbidden, "admin role required")
return
}
user, err := h.database.GetUser(claims.Subject)
if err != nil || !user.Active {
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
return
}
// Re-check role against the live DB row, not just the JWT claim — a
// demoted admin's existing token shouldn't keep working until it
// naturally expires.
if user.Role != db.RoleGlobalAdmin && user.Role != db.RoleTenantAdmin {
writeErr(w, http.StatusForbidden, "admin role required")
return
}
next(w, r, user)
}
}
func (h *Handler) encryptDKIMKey(domainID string, keyPEM []byte) ([]byte, error) {
return crypto.Encrypt(h.mk, domainID, "dkim-key", keyPEM)
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
func (h *Handler) stats(w http.ResponseWriter, r *http.Request, user *db.User) {
s, err := h.database.GetStats()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, s)
}
// ── Tenants (global_admin only) ─────────────────────────────────────────────────
func (h *Handler) tenants(w http.ResponseWriter, r *http.Request, user *db.User) {
if user.Role != db.RoleGlobalAdmin {
writeErr(w, http.StatusForbidden, "only global_admin may manage tenants")
return
}
switch r.Method {
case http.MethodGet:
list, err := h.database.ListTenants()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
case http.MethodPost:
var req struct{ Name, DisplayName string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeErr(w, http.StatusBadRequest, "name is required")
return
}
t := &db.Tenant{ID: uuid.NewString(), Name: req.Name, DisplayName: req.DisplayName}
if err := h.database.CreateTenant(t); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, t)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// ── Domains ───────────────────────────────────────────────────────────────────
func (h *Handler) domains(w http.ResponseWriter, r *http.Request, user *db.User) {
switch r.Method {
case http.MethodGet:
all, err := h.database.ListDomains()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, filterDomainsByTenant(all, scopeTenant(user)))
case http.MethodPost:
var req struct{ Domain, TenantID string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
writeErr(w, http.StatusBadRequest, "domain is required")
return
}
tenantID := req.TenantID
if user.Role != db.RoleGlobalAdmin {
tenantID = user.TenantID
} else if tenantID == "" {
tenantID = user.TenantID // global_admin defaults to their own tenant if unspecified
}
if tenantID == "" {
writeErr(w, http.StatusBadRequest, "tenant_id is required")
return
}
domainID := uuid.NewString()
selector := "mail"
kp, err := dkim.GenerateKeyPair()
if err != nil {
writeErr(w, http.StatusInternalServerError, "DKIM key generation failed: "+err.Error())
return
}
keyEnc, err := h.encryptDKIMKey(domainID, kp.PrivateKeyPEM)
if err != nil {
writeErr(w, http.StatusInternalServerError, "DKIM key encryption failed: "+err.Error())
return
}
d := &db.Domain{ID: domainID, TenantID: tenantID, Domain: req.Domain, DKIMSelector: selector, DKIMPrivateKeyEnc: keyEnc}
if err := h.database.CreateDomain(d); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"domain": d, "dkim_dns_record": kp.DNSRecordValue, "dkim_dns_name": selector + "._domainkey." + req.Domain,
})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) domainByID(w http.ResponseWriter, r *http.Request, user *db.User) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/admin/domains/"), "/")
id := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
d, err := h.database.GetDomain(id)
if err != nil {
writeErr(w, http.StatusNotFound, "domain not found")
return
}
if user.Role != db.RoleGlobalAdmin && d.TenantID != user.TenantID {
writeErr(w, http.StatusForbidden, "not your tenant's domain")
return
}
switch {
case r.Method == http.MethodPost && action == "dkim-rotate":
kp, err := dkim.GenerateKeyPair()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
encKey, err := h.encryptDKIMKey(d.ID, kp.PrivateKeyPEM)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := h.database.UpdateDomainDKIMKey(d.ID, d.DKIMSelector, encKey); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"dkim_dns_record": kp.DNSRecordValue, "dkim_dns_name": d.DKIMSelector + "._domainkey." + d.Domain,
})
case r.Method == http.MethodDelete && action == "":
if err := h.database.DeleteDomain(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// scopeTenant returns the tenant ID a tenant_admin is restricted to, or ""
// for global_admin (meaning "all tenants, no filter").
func scopeTenant(user *db.User) string {
if user.Role == db.RoleGlobalAdmin {
return ""
}
return user.TenantID
}
func filterDomainsByTenant(all []db.Domain, tenantID string) []db.Domain {
if tenantID == "" {
return all
}
var out []db.Domain
for _, d := range all {
if d.TenantID == tenantID {
out = append(out, d)
}
}
return out
}
+6
View File
@@ -0,0 +1,6 @@
package admin
import "embed"
//go:embed static/index.html
var StaticFS embed.FS
+271
View File
@@ -0,0 +1,271 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"gomail/internal/db"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ── Users ─────────────────────────────────────────────────────────────────────
func (h *Handler) users(w http.ResponseWriter, r *http.Request, user *db.User) {
switch r.Method {
case http.MethodGet:
list, err := h.database.ListUsers(scopeTenant(user))
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
case http.MethodPost:
var req struct {
Email, Password, DisplayName, Role, DomainID string
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" || req.Password == "" {
writeErr(w, http.StatusBadRequest, "email and password are required")
return
}
if len(req.Password) < 8 {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if req.Role == "" {
req.Role = string(db.RoleUser)
}
tenantID := user.TenantID
if user.Role != db.RoleGlobalAdmin && req.Role != string(db.RoleUser) {
writeErr(w, http.StatusForbidden, "tenant_admin may only create regular users")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
if err != nil {
writeErr(w, http.StatusInternalServerError, "password hashing failed")
return
}
newUser := &db.User{
ID: uuid.NewString(), TenantID: tenantID, DomainID: req.DomainID,
Email: req.Email, DisplayName: req.DisplayName, Role: db.UserRole(req.Role),
}
if err := h.database.CreateUser(newUser, string(hash)); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, newUser)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) userByID(w http.ResponseWriter, r *http.Request, user *db.User) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/admin/users/"), "/")
id := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
target, err := h.database.GetUser(id)
if err != nil {
writeErr(w, http.StatusNotFound, "user not found")
return
}
if user.Role != db.RoleGlobalAdmin && target.TenantID != user.TenantID {
writeErr(w, http.StatusForbidden, "not your tenant's user")
return
}
if user.Role != db.RoleGlobalAdmin && (target.Role == db.RoleGlobalAdmin || target.Role == db.RoleTenantAdmin) && target.ID != user.ID {
writeErr(w, http.StatusForbidden, "cannot modify an admin account")
return
}
switch {
case r.Method == http.MethodPost && action == "suspend":
if err := h.database.SetUserActive(id, false); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "suspended"})
case r.Method == http.MethodPost && action == "activate":
if err := h.database.SetUserActive(id, true); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "activated"})
case r.Method == http.MethodPost && action == "reset-password":
var req struct{ NewPassword string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.NewPassword) < 8 {
writeErr(w, http.StatusBadRequest, "new_password must be at least 8 characters")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
if err != nil {
writeErr(w, http.StatusInternalServerError, "hashing failed")
return
}
if err := h.database.SetUserPassword(id, string(hash)); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "password reset"})
case r.Method == http.MethodDelete && action == "":
if err := h.database.DeleteUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// ── List rules ────────────────────────────────────────────────────────────────
func (h *Handler) listRules(w http.ResponseWriter, r *http.Request, user *db.User) {
tenantID := user.TenantID
if user.Role == db.RoleGlobalAdmin {
if qt := r.URL.Query().Get("tenant_id"); qt != "" {
tenantID = qt
}
}
if tenantID == "" {
writeErr(w, http.StatusBadRequest, "tenant_id is required")
return
}
switch r.Method {
case http.MethodGet:
rules, err := h.database.ListListRules(tenantID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rules)
case http.MethodPost:
var req struct{ ListType, MatchType, Value, Note string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ListType != "allow" && req.ListType != "block" {
writeErr(w, http.StatusBadRequest, "list_type must be 'allow' or 'block'")
return
}
if req.MatchType != "email" && req.MatchType != "domain" {
writeErr(w, http.StatusBadRequest, "match_type must be 'email' or 'domain'")
return
}
if req.Value == "" {
writeErr(w, http.StatusBadRequest, "value is required")
return
}
rule := &db.ListRule{
ID: uuid.NewString(), TenantID: tenantID,
ListType: db.ListRuleAction(req.ListType), MatchType: req.MatchType, Value: req.Value, Note: req.Note,
}
if err := h.database.CreateListRule(rule); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, rule)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) listRuleByID(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/admin/list-rules/")
if err := h.database.DeleteListRule(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
}
// ── Outbound queue ────────────────────────────────────────────────────────────
func (h *Handler) queue(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
entries, err := h.database.ListAllOutboundQueue()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, entries)
}
func (h *Handler) queueByID(w http.ResponseWriter, r *http.Request, user *db.User) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/admin/queue/"), "/")
id := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
switch {
case r.Method == http.MethodPost && action == "retry":
if err := h.database.RetryQueueEntryNow(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "scheduled for immediate retry"})
case r.Method == http.MethodDelete && action == "":
if err := h.database.DeleteOutboundEntry(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "cancelled"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// ── Global quarantine ─────────────────────────────────────────────────────────
func (h *Handler) quarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
entries, err := h.database.ListAllQuarantine()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, entries)
}
func (h *Handler) quarantineByID(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/admin/quarantine/")
if err := h.database.DeleteQuarantineEntry(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "discarded"})
}
+238
View File
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoMail Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0}
.sidebar{width:200px;background:#1e293b;border-right:1px solid #334155;min-height:100vh;position:fixed;top:0;left:0}
.main{margin-left:200px;padding:28px;max-width:1100px}
.nav-item{padding:9px 16px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:8px;margin:2px 8px}
.nav-item:hover{background:#334155}
.nav-item.active{background:#7c3aed22;color:#a78bfa}
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:18px}
.btn{padding:6px 12px;border-radius:7px;font-size:12px;font-weight:500;cursor:pointer;border:none}
.btn-primary{background:#7c3aed;color:#fff}
.btn-danger{background:#dc262622;color:#f87171;border:1px solid #dc262655}
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}
.inp{background:#0f172a;border:1px solid #334155;border-radius:7px;padding:7px 10px;color:#e2e8f0;font-size:13px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:#64748b;font-weight:500;padding:8px;border-bottom:1px solid #334155}
td{padding:8px;border-bottom:1px solid #1e293b}
.stat{font-size:28px;font-weight:700;color:#fff}
.stat-label{font-size:12px;color:#64748b}
</style>
</head>
<body>
<div id="login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
<div style="background:#1e293b;border:1px solid #334155;border-radius:14px;padding:28px;width:320px">
<h1 style="font-weight:700;color:#fff;text-align:center;margin-bottom:20px">Admin</h1>
<input id="le" class="inp" placeholder="admin@example.com" style="width:100%;margin-bottom:10px;box-sizing:border-box">
<input id="lp" type="password" class="inp" placeholder="Password" style="width:100%;margin-bottom:10px;box-sizing:border-box" onkeydown="if(event.key==='Enter')login()">
<button onclick="login()" class="btn btn-primary" style="width:100%">Sign in</button>
<p id="lerr" style="display:none;color:#f87171;font-size:12px;text-align:center;margin-top:10px"></p>
</div>
</div>
<div id="app" style="display:none">
<aside class="sidebar">
<div style="padding:16px;border-bottom:1px solid #334155;font-weight:700;color:#fff">Admin</div>
<div style="padding:12px 8px">
<div class="nav-item active" onclick="showPage('dashboard',this)">Dashboard</div>
<div class="nav-item" onclick="showPage('domains',this)">Domains</div>
<div class="nav-item" onclick="showPage('users',this)">Users</div>
<div class="nav-item" onclick="showPage('rules',this)">List Rules</div>
<div class="nav-item" onclick="showPage('queue',this)">Queue</div>
<div class="nav-item" onclick="showPage('quarantine',this)">Quarantine</div>
</div>
</aside>
<main class="main">
<div id="page-dashboard">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Dashboard</h2>
<div id="stats-grid" style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px"></div>
</div>
<div id="page-domains" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Domains</h2>
<div class="card" style="margin-bottom:16px">
<input id="d-domain" class="inp" placeholder="example.com">
<button onclick="createDomain()" class="btn btn-primary">Add Domain</button>
</div>
<div class="card"><table id="domains-table"><thead><tr><th>Domain</th><th>ID</th><th>DKIM</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
<div id="page-users" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Users</h2>
<div class="card" style="margin-bottom:16px">
<input id="u-email" class="inp" placeholder="user@example.com">
<input id="u-password" class="inp" type="password" placeholder="Password">
<input id="u-domain-id" class="inp" placeholder="Domain ID (see Domains page)">
<button onclick="createUser()" class="btn btn-primary">Add User</button>
</div>
<div class="card"><table id="users-table"><thead><tr><th>Email</th><th>Role</th><th>Active</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
<div id="page-rules" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">List Rules</h2>
<div class="card" style="margin-bottom:16px">
<select id="r-type" class="inp"><option value="allow">allow</option><option value="block">block</option></select>
<select id="r-match" class="inp"><option value="email">email</option><option value="domain">domain</option></select>
<input id="r-value" class="inp" placeholder="value">
<button onclick="createRule()" class="btn btn-primary">Add Rule</button>
</div>
<div class="card"><table id="rules-table"><thead><tr><th>Type</th><th>Match</th><th>Value</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
<div id="page-queue" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Outbound Queue</h2>
<div class="card"><table id="queue-table"><thead><tr><th>From</th><th>To</th><th>Attempts</th><th>Error</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
<div id="page-quarantine" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Quarantine</h2>
<div class="card"><table id="quarantine-table"><thead><tr><th>Reason</th><th>Held</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
</main>
</div>
<script>
const API='/api/admin';
let token=localStorage.getItem('gomail_admin_token')||'';
async function api(path,opts={}){
const r=await fetch(API+path,{...opts,headers:{'Content-Type':'application/json','Authorization':'Bearer '+token,...(opts.headers||{})}});
if(r.status===401){showLogin();return null;}
return r.ok?r.json():Promise.reject(await r.json());
}
async function login(){
const email=document.getElementById('le').value,pwd=document.getElementById('lp').value;
try{
const d=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({Email:email,Password:pwd})}).then(r=>r.json());
if(d.error)throw new Error(d.error);
token=d.token;localStorage.setItem('gomail_admin_token',token);
showApp();
}catch(e){const el=document.getElementById('lerr');el.textContent=e.message||'Login failed';el.style.display='';}
}
function showLogin(){document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';}
function showApp(){
document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';
loadDashboard();
}
function showPage(name,el){
['dashboard','domains','users','rules','queue','quarantine'].forEach(p=>document.getElementById('page-'+p).style.display=p===name?'block':'none');
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
el.classList.add('active');
({dashboard:loadDashboard,domains:loadDomains,users:loadUsers,rules:loadRules,queue:loadQueue,quarantine:loadQuarantine})[name]();
}
async function loadDashboard(){
const s=await api('/stats');if(!s)return;
document.getElementById('stats-grid').innerHTML=[
['Users',s.TotalUsers],['Domains',s.TotalDomains],['Messages (24h)',s.Messages24h],
['Queue depth',s.QueueDepth],['Quarantine held',s.QuarantineHeld]
].map(function(pair){return '<div class="card"><div class="stat">'+pair[1]+'</div><div class="stat-label">'+pair[0]+'</div></div>';}).join('');
}
async function loadDomains(){
const list=await api('/domains');if(!list)return;
document.querySelector('#domains-table tbody').innerHTML=(list||[]).map(function(d){
return '<tr><td>'+esc(d.Domain)+'</td><td style="font-family:monospace;font-size:11px;cursor:pointer" title="Click to copy" onclick="navigator.clipboard.writeText(\''+d.ID+'\')">'+d.ID.slice(0,8)+'...</td><td>'+(d.DKIMSelector||'-')+'</td>'+
'<td><button onclick="rotateDkim(\''+d.ID+'\')" class="btn btn-ghost">Rotate DKIM</button> '+
'<button onclick="deleteDomain(\''+d.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
}).join('');
}
async function createDomain(){
const domain=document.getElementById('d-domain').value;
if(!domain)return;
try{
const r=await api('/domains',{method:'POST',body:JSON.stringify({Domain:domain})});
alert('Domain created. Publish this DNS TXT record:\n\n'+r.dkim_dns_name+'\n\n'+r.dkim_dns_record);
document.getElementById('d-domain').value='';
loadDomains();
}catch(e){alert('Error: '+(e.error||e.message));}
}
async function rotateDkim(id){
try{
const r=await api('/domains/'+id+'/dkim-rotate',{method:'POST'});
alert('New DKIM key. Update this DNS TXT record:\n\n'+r.dkim_dns_name+'\n\n'+r.dkim_dns_record);
}catch(e){alert('Error: '+(e.error||e.message));}
}
async function deleteDomain(id){
if(!confirm('Delete this domain?'))return;
await api('/domains/'+id,{method:'DELETE'});
loadDomains();
}
async function loadUsers(){
const list=await api('/users');if(!list)return;
document.querySelector('#users-table tbody').innerHTML=(list||[]).map(function(u){
var actionBtn=u.Active?
'<button onclick="suspendUser(\''+u.ID+'\')" class="btn btn-ghost">Suspend</button>':
'<button onclick="activateUser(\''+u.ID+'\')" class="btn btn-ghost">Activate</button>';
return '<tr><td>'+esc(u.Email)+'</td><td>'+u.Role+'</td><td>'+(u.Active?'yes':'no')+'</td>'+
'<td>'+actionBtn+' <button onclick="deleteUser(\''+u.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
}).join('');
}
async function createUser(){
const email=document.getElementById('u-email').value,password=document.getElementById('u-password').value,domainId=document.getElementById('u-domain-id').value;
if(!email||!password||!domainId)return;
try{
await api('/users',{method:'POST',body:JSON.stringify({Email:email,Password:password,DomainID:domainId})});
document.getElementById('u-email').value='';document.getElementById('u-password').value='';document.getElementById('u-domain-id').value='';
loadUsers();
}catch(e){alert('Error: '+(e.error||e.message));}
}
async function suspendUser(id){await api('/users/'+id+'/suspend',{method:'POST'});loadUsers();}
async function activateUser(id){await api('/users/'+id+'/activate',{method:'POST'});loadUsers();}
async function deleteUser(id){if(!confirm('Delete this user?'))return;await api('/users/'+id,{method:'DELETE'});loadUsers();}
async function loadRules(){
const list=await api('/list-rules');if(!list)return;
document.querySelector('#rules-table tbody').innerHTML=(list||[]).map(function(r){
return '<tr><td>'+r.ListType+'</td><td>'+r.MatchType+'</td><td>'+esc(r.Value)+'</td>'+
'<td><button onclick="deleteRule(\''+r.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
}).join('');
}
async function createRule(){
const listType=document.getElementById('r-type').value,matchType=document.getElementById('r-match').value,value=document.getElementById('r-value').value;
if(!value)return;
try{
await api('/list-rules',{method:'POST',body:JSON.stringify({ListType:listType,MatchType:matchType,Value:value})});
document.getElementById('r-value').value='';
loadRules();
}catch(e){alert('Error: '+(e.error||e.message));}
}
async function deleteRule(id){await api('/list-rules/'+id,{method:'DELETE'});loadRules();}
async function loadQueue(){
const list=await api('/queue');if(!list)return;
document.querySelector('#queue-table tbody').innerHTML=(list||[]).map(function(q){
return '<tr><td>'+esc(q.FromAddress)+'</td><td>'+esc(q.ToAddress)+'</td><td>'+q.Attempts+'</td>'+
'<td style="color:#f87171">'+esc(q.LastError||'')+'</td>'+
'<td><button onclick="retryQueue(\''+q.ID+'\')" class="btn btn-ghost">Retry now</button> '+
'<button onclick="cancelQueue(\''+q.ID+'\')" class="btn btn-danger">Cancel</button></td></tr>';
}).join('');
}
async function retryQueue(id){await api('/queue/'+id+'/retry',{method:'POST'});loadQueue();}
async function cancelQueue(id){await api('/queue/'+id,{method:'DELETE'});loadQueue();}
async function loadQuarantine(){
const list=await api('/quarantine');if(!list)return;
document.querySelector('#quarantine-table tbody').innerHTML=(list&&list.length)?list.map(function(q){
return '<tr><td>'+esc(q.Reason||'-')+'</td><td>'+q.CreatedAt+'</td>'+
'<td><button onclick="discardQuarantine(\''+q.ID+'\')" class="btn btn-danger">Discard</button></td></tr>';
}).join(''):'<tr><td colspan="3" style="text-align:center;color:#475569;padding:20px">Nothing held</td></tr>';
}
async function discardQuarantine(id){await api('/quarantine/'+id,{method:'DELETE'});loadQuarantine();}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
if(!token){showLogin();}else{showApp();}
</script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
// Package auth provides shared credential verification for every protocol
// that needs it (SMTP AUTH, IMAP LOGIN, POP3 USER/PASS) — a single source of
// truth for how a username/password pair maps to a user, so a future change
// (MFA enforcement, passkey-only accounts, lockout policy) only needs to
// land in one place.
package auth
import (
"log/slog"
"strings"
"time"
"gomail/internal/db"
"golang.org/x/crypto/bcrypt"
)
// Scope identifies which protocol is authenticating — checked against an app
// password's comma-separated scopes column so a password minted for "imap"
// can't be used to relay outbound SMTP, etc.
type Scope string
const (
ScopeSMTP Scope = "smtp"
ScopeIMAP Scope = "imap"
ScopePOP3 Scope = "pop3"
ScopeCalDAV Scope = "caldav"
ScopeCardDAV Scope = "carddav"
)
// Authenticate verifies a username/password against either the user's main
// account password or one of their active, non-expired app passwords scoped
// for the given protocol. Returns the user and true on success.
func Authenticate(database *db.DB, username, password string, scope Scope) (*db.User, bool) {
user, err := database.LookupUserByEmail(strings.ToLower(strings.TrimSpace(username)))
if err != nil {
// Always compare against a dummy hash even on lookup failure — avoids
// leaking "user exists vs doesn't" via response timing.
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password))
return nil, false
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil {
return user, true
}
if checkAppPassword(database, user.ID, password, scope) {
return user, true
}
return nil, false
}
func checkAppPassword(database *db.DB, userID, password string, scope Scope) bool {
rows, err := database.Query(`
SELECT id, password_hash, scopes, expires_at FROM app_passwords
WHERE user_id = ? AND (expires_at IS NULL OR expires_at > ?)
`, userID, time.Now().UTC())
if err != nil {
slog.Error("app password lookup failed", "err", err)
return false
}
defer rows.Close()
for rows.Next() {
var id, hash, scopes string
var expiresAt *time.Time
if err := rows.Scan(&id, &hash, &scopes, &expiresAt); err != nil {
continue
}
if !strings.Contains(scopes, string(scope)) && !strings.Contains(scopes, "all") {
continue
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil {
go database.Exec(`UPDATE app_passwords SET last_used_at = ? WHERE id = ?`, time.Now().UTC(), id)
return true
}
}
return false
}
// dummyHash is a valid bcrypt hash of a random unguessable string, used only
// to equalize timing when a username lookup fails.
const dummyHash = "$2a$12$gT3vXk8yZ1pQzM4nR7wS8eK9vL2mN5oP1qR3sT6uV8wX0yZ2aB4cD"
+308
View File
@@ -0,0 +1,308 @@
// Package config loads gomail.yaml, applies environment variable overrides for
// secrets, and generates a documented example config on first run.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
TLS TLSConfig `yaml:"tls"`
Database DatabaseConfig `yaml:"database"`
Storage StorageConfig `yaml:"storage"`
RateLimits RateLimitConfig `yaml:"rate_limits"`
Pipeline PipelineConfig `yaml:"pipeline"`
Notify NotifyConfig `yaml:"notify"`
POP3 POP3Config `yaml:"pop3"`
JMAP JMAPConfig `yaml:"jmap"`
OAuth OAuthConfig `yaml:"oauth"`
LinkedAccounts LinkedAccountsConfig `yaml:"linked_accounts"`
Security SecurityConfig `yaml:"-"` // populated entirely from env, never serialized
}
type ServerConfig struct {
Hostname string `yaml:"hostname"`
SMTPAddr string `yaml:"smtp_addr"`
SubmissionAddr string `yaml:"submission_addr"`
SMTPSAddr string `yaml:"smtps_addr"`
IMAPAddr string `yaml:"imap_addr"`
IMAPSAddr string `yaml:"imaps_addr"`
WebmailAddr string `yaml:"webmail_addr"`
AdminAddr string `yaml:"admin_addr"`
DAVAddr string `yaml:"dav_addr"`
ManageSieveAddr string `yaml:"managesieve_addr"`
RealIPHeader string `yaml:"real_ip_header"`
AdminIPAllowlist []string `yaml:"admin_ip_allowlist"`
}
type TLSConfig struct {
Mode string `yaml:"mode"` // acme | file | off
ACMEEmail string `yaml:"acme_email"`
ACMEDomains []string `yaml:"acme_domains"`
ACMEDirectoryURL string `yaml:"acme_directory_url"` // defaults to real Let's Encrypt production; override for staging/testing
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
MinVersion string `yaml:"min_version"`
}
type DatabaseConfig struct {
Driver string `yaml:"driver"` // sqlite | postgres | mysql
DSN string `yaml:"dsn"`
}
type StorageConfig struct {
MaildirRoot string `yaml:"maildir_root"`
RetentionDays int `yaml:"retention_days"`
QuarantineDays int `yaml:"quarantine_days"`
MaxMessageSizeMB int `yaml:"max_message_size_mb"`
}
type RateLimitConfig struct {
SMTPConnPerMin int `yaml:"smtp_conn_per_min"`
SMTPAuthFailures int `yaml:"smtp_auth_failures"`
IMAPConnPerMin int `yaml:"imap_conn_per_min"`
IMAPAuthFailures int `yaml:"imap_auth_failures"`
POP3AuthFailures int `yaml:"pop3_auth_failures"`
HTTPReqPerMin int `yaml:"http_req_per_min"`
}
type PipelineConfig struct {
ScoreFlag float64 `yaml:"score_flag"`
ScoreQuarantine float64 `yaml:"score_quarantine"`
ScoreBlock float64 `yaml:"score_block"`
ClamAVSocket string `yaml:"clamav_socket"`
RspamdURL string `yaml:"rspamd_url"`
LLMURL string `yaml:"llm_url"`
LLMModel string `yaml:"llm_model"`
LLMTimeoutSecs int `yaml:"llm_timeout_secs"`
}
type NotifyConfig struct {
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
SMTPUser string `yaml:"smtp_user"`
FromAddress string `yaml:"from_address"`
DefaultDigestIntervalMins int `yaml:"default_digest_interval_mins"`
}
type POP3Config struct {
Enabled bool `yaml:"enabled"` // off by default — legacy, opt-in
POP3Addr string `yaml:"pop3_addr"`
POP3SAddr string `yaml:"pop3s_addr"`
}
type JMAPConfig struct {
ExternalEnabled bool `yaml:"external_enabled"` // off by default
ExternalAddr string `yaml:"external_addr"`
}
type OAuthConfig struct {
Google OAuthProviderConfig `yaml:"google"`
Microsoft OAuthProviderConfig `yaml:"microsoft"`
}
type OAuthProviderConfig struct {
Enabled bool `yaml:"enabled"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret,omitempty"` // prefer env override
Tenant string `yaml:"tenant,omitempty"` // microsoft only
RedirectURI string `yaml:"redirect_uri"`
}
type LinkedAccountsConfig struct {
DefaultCacheRetention string `yaml:"default_cache_retention"` // e.g. "90d"
MaxCacheRetention string `yaml:"max_cache_retention"` // e.g. "3y"
CacheSweepInterval string `yaml:"cache_sweep_interval"` // e.g. "24h"
SyncPollIntervalSecs int `yaml:"sync_poll_interval_secs"`
}
// SecurityConfig holds every secret. Populated ONLY from environment variables —
// never read from or written to the YAML config file.
type SecurityConfig struct {
MasterKey string // GOMAIL_MASTER_KEY (32-byte hex)
MasterKeyPrev string // GOMAIL_MASTER_KEY_PREV (during rotation)
JWTSecret string // GOMAIL_JWT_SECRET
AdminInitPassword string // GOMAIL_ADMIN_INIT_PASSWORD
NotifySMTPPassword string // GOMAIL_NOTIFY_SMTP_PASSWORD
OAuthGoogleSecret string // GOMAIL_OAUTH_GOOGLE_SECRET
OAuthMicrosoftSecret string // GOMAIL_OAUTH_MICROSOFT_SECRET
DBDSNOverride string // GOMAIL_DB_DSN
BcryptCost int // GOMAIL_BCRYPT_COST (default 12)
}
// Load reads the YAML config at path, auto-generating a default one if it does
// not exist, then applies environment variable overrides for all secrets.
func Load(path string) (*Config, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := writeDefault(path); err != nil {
return nil, fmt.Errorf("generating default config: %w", err)
}
fmt.Printf("No config found — generated default at %s. Review it before production use.\n", path)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
cfg := Default()
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
applyEnvOverrides(cfg)
if err := validate(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func applyEnvOverrides(cfg *Config) {
cfg.Security = SecurityConfig{
MasterKey: os.Getenv("GOMAIL_MASTER_KEY"),
MasterKeyPrev: os.Getenv("GOMAIL_MASTER_KEY_PREV"),
JWTSecret: os.Getenv("GOMAIL_JWT_SECRET"),
AdminInitPassword: os.Getenv("GOMAIL_ADMIN_INIT_PASSWORD"),
NotifySMTPPassword: os.Getenv("GOMAIL_NOTIFY_SMTP_PASSWORD"),
OAuthGoogleSecret: os.Getenv("GOMAIL_OAUTH_GOOGLE_SECRET"),
OAuthMicrosoftSecret: os.Getenv("GOMAIL_OAUTH_MICROSOFT_SECRET"),
DBDSNOverride: os.Getenv("GOMAIL_DB_DSN"),
BcryptCost: 12,
}
if cfg.Security.DBDSNOverride != "" {
cfg.Database.DSN = cfg.Security.DBDSNOverride
}
if cfg.Security.OAuthGoogleSecret != "" {
cfg.OAuth.Google.ClientSecret = cfg.Security.OAuthGoogleSecret
}
if cfg.Security.OAuthMicrosoftSecret != "" {
cfg.OAuth.Microsoft.ClientSecret = cfg.Security.OAuthMicrosoftSecret
}
}
func validate(cfg *Config) error {
if cfg.Security.MasterKey == "" {
return fmt.Errorf("GOMAIL_MASTER_KEY environment variable is required (32-byte hex — generate with: openssl rand -hex 32)")
}
if len(cfg.Security.MasterKey) != 64 {
return fmt.Errorf("GOMAIL_MASTER_KEY must be 64 hex characters (32 bytes), got %d characters", len(cfg.Security.MasterKey))
}
if cfg.Security.JWTSecret == "" {
return fmt.Errorf("GOMAIL_JWT_SECRET environment variable is required (generate with: openssl rand -hex 32)")
}
if len(cfg.Security.JWTSecret) < 32 {
return fmt.Errorf("GOMAIL_JWT_SECRET must be at least 32 characters, got %d (generate with: openssl rand -hex 32)", len(cfg.Security.JWTSecret))
}
if cfg.Server.Hostname == "" {
return fmt.Errorf("server.hostname must be set in config")
}
return nil
}
// Default returns a Config populated with sane defaults (used as the base
// before YAML unmarshal, so any keys missing from the file keep these values).
func Default() *Config {
return &Config{
Server: ServerConfig{
Hostname: "mail.example.com",
SMTPAddr: ":25",
SubmissionAddr: ":587",
SMTPSAddr: ":465",
IMAPAddr: ":143",
IMAPSAddr: ":993",
WebmailAddr: "127.0.0.1:8080",
AdminAddr: "127.0.0.1:9090",
DAVAddr: "127.0.0.1:8443",
ManageSieveAddr: ":4190",
RealIPHeader: "X-Forwarded-For",
AdminIPAllowlist: []string{"127.0.0.1", "::1"},
},
TLS: TLSConfig{
Mode: "acme",
ACMEDirectoryURL: "https://acme-v02.api.letsencrypt.org/directory",
MinVersion: "TLS12",
},
Database: DatabaseConfig{
Driver: "sqlite",
DSN: "file:/var/lib/gomail/gomail.db?_journal_mode=WAL&_foreign_keys=on",
},
Storage: StorageConfig{
MaildirRoot: "/var/mail/gomail",
RetentionDays: 365,
QuarantineDays: 30,
MaxMessageSizeMB: 50,
},
RateLimits: RateLimitConfig{
SMTPConnPerMin: 20,
SMTPAuthFailures: 5,
IMAPConnPerMin: 60,
IMAPAuthFailures: 5,
POP3AuthFailures: 5,
HTTPReqPerMin: 120,
},
Pipeline: PipelineConfig{
ScoreFlag: 20,
ScoreQuarantine: 50,
ScoreBlock: 80,
LLMModel: "llama3.2-3b-instruct",
LLMTimeoutSecs: 30,
},
Notify: NotifyConfig{
SMTPPort: 587,
FromAddress: "noreply@example.com",
DefaultDigestIntervalMins: 60,
},
POP3: POP3Config{
Enabled: false,
POP3Addr: ":110",
POP3SAddr: ":995",
},
JMAP: JMAPConfig{
ExternalEnabled: false,
ExternalAddr: "0.0.0.0:8443",
},
OAuth: OAuthConfig{
Google: OAuthProviderConfig{Enabled: false},
Microsoft: OAuthProviderConfig{Enabled: false, Tenant: "common"},
},
LinkedAccounts: LinkedAccountsConfig{
DefaultCacheRetention: "90d",
MaxCacheRetention: "3y",
CacheSweepInterval: "24h",
SyncPollIntervalSecs: 120,
},
}
}
func writeDefault(path string) error {
cfg := Default()
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
header := `# gomail.yaml — auto-generated. Review before production use.
#
# Secrets are NOT stored here — set these environment variables instead:
# GOMAIL_MASTER_KEY 32-byte hex, message/contact/calendar encryption key
# generate with: openssl rand -hex 32
# GOMAIL_JWT_SECRET 32+ byte random, session signing
# generate with: openssl rand -hex 32
# GOMAIL_ADMIN_INIT_PASSWORD first-run global admin password
# GOMAIL_DB_DSN overrides database.dsn below
# GOMAIL_NOTIFY_SMTP_PASSWORD outbound notification SMTP password
# GOMAIL_OAUTH_GOOGLE_SECRET Google OAuth2 client secret
# GOMAIL_OAUTH_MICROSOFT_SECRET Microsoft OAuth2 client secret
# GOMAIL_MASTER_KEY_PREV previous master key, only during key rotation
`
full := append([]byte(header), data...)
return os.WriteFile(path, full, 0640)
}
+166
View File
@@ -0,0 +1,166 @@
// Package crypto provides encryption-at-rest for messages, contacts, and
// calendar data. Every record gets its own key, derived from the master key
// via HKDF — compromising one encrypted file never exposes the master key or
// any other record.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"golang.org/x/crypto/hkdf"
)
const (
keySize = 32 // AES-256
nonceSize = 12 // GCM standard nonce size
)
// MasterKey holds the decoded master key(s) used to derive per-record keys.
// Two keys allow decrypting old data during a key-rotation window: Current is
// used for new writes, Previous (if set) is tried as a fallback on decrypt.
type MasterKey struct {
Current []byte
Previous []byte // nil if not rotating
}
// LoadMasterKey decodes hex-encoded master key(s) from config/env values.
func LoadMasterKey(currentHex, previousHex string) (*MasterKey, error) {
cur, err := decodeKey(currentHex)
if err != nil {
return nil, fmt.Errorf("master key: %w", err)
}
mk := &MasterKey{Current: cur}
if previousHex != "" {
prev, err := decodeKey(previousHex)
if err != nil {
return nil, fmt.Errorf("previous master key: %w", err)
}
mk.Previous = prev
}
return mk, nil
}
func decodeKey(hexStr string) ([]byte, error) {
b, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(b) != keySize {
return nil, fmt.Errorf("must be %d bytes (%d hex chars), got %d bytes", keySize, keySize*2, len(b))
}
return b, nil
}
// deriveKey produces a per-record 32-byte key from the master key using HKDF-SHA256.
// recordID should be a stable, unique identifier for the record (e.g. message ID,
// contact UID) — using the same recordID always derives the same key, which is
// required for decryption to work.
func deriveKey(master []byte, recordID string, purpose string) ([]byte, error) {
info := []byte(purpose + ":" + recordID)
r := hkdf.New(sha256.New, master, nil, info)
key := make([]byte, keySize)
if _, err := io.ReadFull(r, key); err != nil {
return nil, fmt.Errorf("hkdf derive: %w", err)
}
return key, nil
}
// Encrypt encrypts plaintext with a key derived from the master key and
// recordID. purpose namespaces the derivation (e.g. "message", "contact",
// "calendar", "dkim-key") so the same recordID used for different data types
// never collides. Output format: [12-byte nonce][ciphertext][16-byte GCM tag].
func Encrypt(mk *MasterKey, recordID, purpose string, plaintext []byte) ([]byte, error) {
key, err := deriveKey(mk.Current, recordID, purpose)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
nonce := make([]byte, nonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
return append(nonce, ciphertext...), nil
}
// Decrypt decrypts data produced by Encrypt. It tries the current master key
// first, then falls back to the previous key (if set) — this lets records
// written before a key rotation still be read without a bulk re-encryption
// pass; callers should re-encrypt with the current key on next write if a
// fallback decrypt succeeds.
func Decrypt(mk *MasterKey, recordID, purpose string, data []byte) ([]byte, error) {
if len(data) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
if pt, err := decryptWith(mk.Current, recordID, purpose, nonce, ciphertext); err == nil {
return pt, nil
}
if mk.Previous != nil {
if pt, err := decryptWith(mk.Previous, recordID, purpose, nonce, ciphertext); err == nil {
return pt, nil
}
}
return nil, fmt.Errorf("decrypt failed with current%s key",
map[bool]string{true: " and previous", false: ""}[mk.Previous != nil])
}
func decryptWith(master []byte, recordID, purpose string, nonce, ciphertext []byte) ([]byte, error) {
key, err := deriveKey(master, recordID, purpose)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return gcm.Open(nil, nonce, ciphertext, nil)
}
// NeedsReencryption reports whether data was decrypted using the previous
// (not current) master key — callers use this to trigger lazy re-encryption
// on read during a key rotation window.
func NeedsReencryption(mk *MasterKey, recordID, purpose string, data []byte) bool {
if mk.Previous == nil || len(data) < nonceSize {
return false
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
if _, err := decryptWith(mk.Current, recordID, purpose, nonce, ciphertext); err == nil {
return false // current key works fine
}
_, err := decryptWith(mk.Previous, recordID, purpose, nonce, ciphertext)
return err == nil
}
// GenerateMasterKeyHex is a convenience helper for CLI tooling / setup docs —
// produces a fresh random master key as hex, ready to paste into GOMAIL_MASTER_KEY.
func GenerateMasterKeyHex() (string, error) {
b := make([]byte, keySize)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+471
View File
@@ -0,0 +1,471 @@
// Package dav implements a CalDAV (RFC 4791) + CardDAV (RFC 6352) HTTP
// server covering the core operations real clients need: PROPFIND (Depth 0/1
// discovery), REPORT (calendar-query/multiget, addressbook-query/multiget —
// query filtering returns all objects in the collection in this pass, full
// time-range/property filtering deferred), PUT (create/update), GET
// (fetch), DELETE, OPTIONS. No MKCALENDAR/MKCOL — every user's default
// addressbook and calendar are auto-created on first access instead, which
// covers the common case (one addressbook, one calendar per user) without
// needing collection-creation UI in an early phase.
//
// URL layout:
//
// /dav/contacts/{ownerType}/{ownerID}/ addressbook collection
// /dav/contacts/{ownerType}/{ownerID}/{uid}.vcf a contact
// /dav/calendars/{ownerType}/{ownerID}/ calendar collection
// /dav/calendars/{ownerType}/{ownerID}/{uid}.ics a calendar event
package dav
import (
"encoding/xml"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"gomail/internal/auth"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/ical"
"gomail/internal/vcard"
"github.com/google/uuid"
)
type Handler struct {
database *db.DB
mk *crypto.MasterKey
}
func NewHandler(database *db.DB, mk *crypto.MasterKey) *Handler {
return &Handler{database: database, mk: mk}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticate(r)
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="GoMail DAV"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, "/dav")
switch {
case strings.HasPrefix(path, "/contacts/"):
h.serveCardDAV(w, r, user, strings.TrimPrefix(path, "/contacts/"))
case strings.HasPrefix(path, "/calendars/"):
h.serveCalDAV(w, r, user, strings.TrimPrefix(path, "/calendars/"))
default:
http.NotFound(w, r)
}
}
func (h *Handler) authenticate(r *http.Request) (*db.User, bool) {
username, password, ok := r.BasicAuth()
if !ok {
return nil, false
}
return auth.Authenticate(h.database, username, password, auth.ScopeCardDAV)
}
// ── CardDAV ───────────────────────────────────────────────────────────────────
func (h *Handler) serveCardDAV(w http.ResponseWriter, r *http.Request, user *db.User, rest string) {
ownerType, ownerID, uid, ok := parseCollectionPath(rest, user)
if !ok {
http.NotFound(w, r)
return
}
book, err := h.database.GetOrCreateAddressbook(ownerType, ownerID, "Default")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
switch r.Method {
case "OPTIONS":
w.Header().Set("DAV", "1, 2, addressbook")
w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE")
w.WriteHeader(http.StatusOK)
case "PROPFIND":
h.propfindContacts(w, r, book, uid)
case "REPORT":
h.reportContacts(w, r, book)
case http.MethodGet:
if uid == "" {
http.Error(w, "GET on collection not supported, use PROPFIND", http.StatusMethodNotAllowed)
return
}
contact, err := h.database.GetContact(book.ID, strings.TrimSuffix(uid, ".vcf"))
if err != nil {
http.NotFound(w, r)
return
}
plain, err := crypto.Decrypt(h.mk, contact.ID, "contact", contact.VCardEnc)
if err != nil {
http.Error(w, "decrypt error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/vcard; charset=utf-8")
w.Header().Set("ETag", contact.ETag)
w.Write(plain)
case http.MethodPut:
if uid == "" {
http.Error(w, "PUT requires a resource path", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
card, err := vcard.Parse(string(body))
if err != nil {
http.Error(w, "invalid vCard: "+err.Error(), http.StatusBadRequest)
return
}
contactID := uuid.NewString()
if existing, err := h.database.GetContact(book.ID, card.UID); err == nil {
contactID = existing.ID
}
encrypted, err := crypto.Encrypt(h.mk, contactID, "contact", body)
if err != nil {
http.Error(w, "encrypt error", http.StatusInternalServerError)
return
}
etag := fmt.Sprintf(`"%d"`, time.Now().UnixNano())
if err := h.database.UpsertContact(&db.Contact{
ID: contactID, AddressbookID: book.ID, UID: card.UID, VCardEnc: encrypted, ETag: etag,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusCreated)
case http.MethodDelete:
if uid == "" {
http.Error(w, "DELETE requires a resource path", http.StatusBadRequest)
return
}
if err := h.database.DeleteContact(book.ID, strings.TrimSuffix(uid, ".vcf")); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) propfindContacts(w http.ResponseWriter, r *http.Request, book *db.Addressbook, uid string) {
depth := r.Header.Get("Depth")
var responses []multistatusResponse
responses = append(responses, multistatusResponse{
Href: r.URL.Path,
Props: propSet{
DisplayName: book.DisplayName,
ResourceType: "<collection xmlns=\"DAV:\"/><addressbook xmlns=\"urn:ietf:params:xml:ns:carddav\"/>",
},
})
if depth == "1" && uid == "" {
contacts, err := h.database.ListContacts(book.ID)
if err == nil {
for _, c := range contacts {
responses = append(responses, multistatusResponse{
Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + c.UID + ".vcf",
Props: propSet{ETag: c.ETag, ContentType: "text/vcard; charset=utf-8"},
})
}
}
}
writeMultistatus(w, responses)
}
func (h *Handler) reportContacts(w http.ResponseWriter, r *http.Request, book *db.Addressbook) {
// addressbook-query and addressbook-multiget both return every contact's
// current vCard in this pass — full filter/prop-match parsing is
// deferred; clients doing a multiget for hrefs they already have (the
// common sync pattern) get correct data, just not a filtered subset.
contacts, err := h.database.ListContacts(book.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var responses []multistatusResponse
for _, c := range contacts {
plain, err := crypto.Decrypt(h.mk, c.ID, "contact", c.VCardEnc)
if err != nil {
continue
}
responses = append(responses, multistatusResponse{
Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + c.UID + ".vcf",
Props: propSet{ETag: c.ETag},
AddressData: string(plain),
})
}
writeMultistatus(w, responses)
}
// ── CalDAV ────────────────────────────────────────────────────────────────────
func (h *Handler) serveCalDAV(w http.ResponseWriter, r *http.Request, user *db.User, rest string) {
ownerType, ownerID, uid, ok := parseCollectionPath(rest, user)
if !ok {
http.NotFound(w, r)
return
}
cal, err := h.database.GetOrCreateCalendar(ownerType, ownerID, "Default")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
switch r.Method {
case "OPTIONS":
w.Header().Set("DAV", "1, 2, calendar-access")
w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE")
w.WriteHeader(http.StatusOK)
case "PROPFIND":
h.propfindCalendar(w, r, cal, uid)
case "REPORT":
h.reportCalendar(w, r, cal)
case http.MethodGet:
if uid == "" {
http.Error(w, "GET on collection not supported, use PROPFIND", http.StatusMethodNotAllowed)
return
}
obj, err := h.database.GetCalendarObject(cal.ID, strings.TrimSuffix(uid, ".ics"))
if err != nil {
http.NotFound(w, r)
return
}
plain, err := crypto.Decrypt(h.mk, obj.ID, "calendar", obj.ICalEnc)
if err != nil {
http.Error(w, "decrypt error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("ETag", obj.ETag)
w.Write(plain)
case http.MethodPut:
if uid == "" {
http.Error(w, "PUT requires a resource path", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
event, err := ical.Parse(string(body))
if err != nil {
http.Error(w, "invalid iCal: "+err.Error(), http.StatusBadRequest)
return
}
objID := uuid.NewString()
if existing, err := h.database.GetCalendarObject(cal.ID, event.UID); err == nil {
objID = existing.ID
}
encrypted, err := crypto.Encrypt(h.mk, objID, "calendar", body)
if err != nil {
http.Error(w, "encrypt error", http.StatusInternalServerError)
return
}
etag := fmt.Sprintf(`"%d"`, time.Now().UnixNano())
obj := &db.CalendarObject{
ID: objID, CalendarID: cal.ID, UID: event.UID, ICalEnc: encrypted,
ComponentType: "VEVENT", Summary: event.Summary, ETag: etag,
}
if !event.DTStart.IsZero() {
obj.DTStart = &event.DTStart
}
if !event.DTEnd.IsZero() {
obj.DTEnd = &event.DTEnd
}
if err := h.database.UpsertCalendarObject(obj); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusCreated)
case http.MethodDelete:
if uid == "" {
http.Error(w, "DELETE requires a resource path", http.StatusBadRequest)
return
}
if err := h.database.DeleteCalendarObject(cal.ID, strings.TrimSuffix(uid, ".ics")); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) propfindCalendar(w http.ResponseWriter, r *http.Request, cal *db.Calendar, uid string) {
depth := r.Header.Get("Depth")
var responses []multistatusResponse
responses = append(responses, multistatusResponse{
Href: r.URL.Path,
Props: propSet{
DisplayName: cal.DisplayName,
ResourceType: "<collection xmlns=\"DAV:\"/><calendar xmlns=\"urn:ietf:params:xml:ns:caldav\"/>",
},
})
if depth == "1" && uid == "" {
objs, err := h.database.ListCalendarObjects(cal.ID)
if err == nil {
for _, o := range objs {
responses = append(responses, multistatusResponse{
Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + o.UID + ".ics",
Props: propSet{ETag: o.ETag, ContentType: "text/calendar; charset=utf-8"},
})
}
}
}
writeMultistatus(w, responses)
}
func (h *Handler) reportCalendar(w http.ResponseWriter, r *http.Request, cal *db.Calendar) {
// calendar-query and calendar-multiget both return every event in this
// pass — time-range filtering (the most common real-world calendar-query
// use, "give me events this week") is deferred; noted here rather than
// silently ignored, since clients that rely on server-side time-range
// filtering will over-fetch until that lands.
objs, err := h.database.ListCalendarObjects(cal.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var responses []multistatusResponse
for _, o := range objs {
plain, err := crypto.Decrypt(h.mk, o.ID, "calendar", o.ICalEnc)
if err != nil {
continue
}
responses = append(responses, multistatusResponse{
Href: strings.TrimSuffix(r.URL.Path, "/") + "/" + o.UID + ".ics",
Props: propSet{ETag: o.ETag},
CalendarData: string(plain),
})
}
writeMultistatus(w, responses)
}
// ── Path parsing ──────────────────────────────────────────────────────────────
// parseCollectionPath extracts (ownerType, ownerID, resourceUID) from a
// request path like "user/{userID}/{uid}.vcf" or "tenant/{tenantID}/". Only
// allows a user to address their own personal collection or their own
// tenant's shared one — cross-user access is rejected.
func parseCollectionPath(rest string, requestingUser *db.User) (db.OwnerType, string, string, bool) {
parts := strings.SplitN(strings.TrimPrefix(rest, "/"), "/", 3)
if len(parts) < 2 {
return "", "", "", false
}
ownerType := db.OwnerType(parts[0])
ownerID := parts[1]
uid := ""
if len(parts) == 3 {
uid = parts[2]
}
switch ownerType {
case db.OwnerUser:
if ownerID != requestingUser.ID {
return "", "", "", false // no cross-user access
}
case db.OwnerTenant:
if ownerID != requestingUser.TenantID {
return "", "", "", false // no cross-tenant access
}
default:
return "", "", "", false
}
return ownerType, ownerID, uid, true
}
// ── Multistatus XML ───────────────────────────────────────────────────────────
type propSet struct {
DisplayName string
ResourceType string // raw XML fragment, since it varies by collection type
ETag string
ContentType string
}
type multistatusResponse struct {
Href string
Props propSet
AddressData string // set only for CardDAV REPORT responses
CalendarData string // set only for CalDAV REPORT responses
}
func writeMultistatus(w http.ResponseWriter, responses []multistatusResponse) {
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav" xmlns:CAL="urn:ietf:params:xml:ns:caldav">` + "\n")
for _, r := range responses {
b.WriteString(" <D:response>\n")
b.WriteString(" <D:href>" + xmlEscape(r.Href) + "</D:href>\n")
b.WriteString(" <D:propstat>\n <D:prop>\n")
if r.Props.DisplayName != "" {
b.WriteString(" <D:displayname>" + xmlEscape(r.Props.DisplayName) + "</D:displayname>\n")
}
if r.Props.ResourceType != "" {
b.WriteString(" <D:resourcetype>" + r.Props.ResourceType + "</D:resourcetype>\n")
}
if r.Props.ETag != "" {
b.WriteString(" <D:getetag>" + xmlEscape(r.Props.ETag) + "</D:getetag>\n")
}
if r.Props.ContentType != "" {
b.WriteString(" <D:getcontenttype>" + xmlEscape(r.Props.ContentType) + "</D:getcontenttype>\n")
}
if r.AddressData != "" {
b.WriteString(" <C:address-data>" + xmlEscape(r.AddressData) + "</C:address-data>\n")
}
if r.CalendarData != "" {
b.WriteString(" <CAL:calendar-data>" + xmlEscape(r.CalendarData) + "</CAL:calendar-data>\n")
}
b.WriteString(" </D:prop>\n <D:status>HTTP/1.1 200 OK</D:status>\n </D:propstat>\n")
b.WriteString(" </D:response>\n")
}
b.WriteString("</D:multistatus>\n")
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(207) // Multi-Status
if _, err := w.Write([]byte(b.String())); err != nil {
slog.Debug("dav: write error", "err", err)
}
}
func xmlEscape(s string) string {
var b strings.Builder
xml.EscapeText(&b, []byte(s))
return b.String()
}
+96
View File
@@ -0,0 +1,96 @@
package db
import (
"fmt"
"log/slog"
"gomail/internal/crypto"
"gomail/internal/dkim"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// Bootstrap creates an initial tenant, domain, and global admin user on first
// run — detected by the absence of any global_admin row. Safe to call on
// every startup; it's a no-op once bootstrapped.
func (db *DB) Bootstrap(hostname, initPassword string, bcryptCost int, mk *crypto.MasterKey) error {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'global_admin'").Scan(&count)
if err != nil {
return fmt.Errorf("checking existing admins: %w", err)
}
if count > 0 {
return nil // already bootstrapped
}
if initPassword == "" {
initPassword = "ChangeMe123!"
slog.Warn("no admin exists and GOMAIL_ADMIN_INIT_PASSWORD not set — using default, CHANGE IMMEDIATELY",
"password", initPassword, "email", "admin@"+hostname)
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
tenantID := uuid.NewString()
if _, err := tx.Exec(
`INSERT INTO tenants (id, name, display_name) VALUES (?, ?, ?)`,
tenantID, "default", "Default Tenant",
); err != nil {
return fmt.Errorf("creating default tenant: %w", err)
}
domainID := uuid.NewString()
dkimSelector := "mail"
// Generate a DKIM key pair so outbound mail can be signed immediately —
// without this, every message this instance sends would be unsigned
// until an admin manually configures one (Phase 8's admin portal will
// add key rotation/regeneration; this just ensures a working default).
var dkimKeyEnc []byte
kp, kpErr := dkim.GenerateKeyPair()
if kpErr != nil {
slog.Warn("failed to generate DKIM key during bootstrap — outbound mail will be unsigned until one is configured", "err", kpErr)
} else {
encrypted, encErr := crypto.Encrypt(mk, domainID, "dkim-key", kp.PrivateKeyPEM)
if encErr != nil {
slog.Warn("failed to encrypt DKIM key during bootstrap", "err", encErr)
} else {
dkimKeyEnc = encrypted
slog.Info("DKIM key generated for default domain — publish this DNS TXT record",
"record_name", dkimSelector+"._domainkey."+hostname,
"record_value", kp.DNSRecordValue)
}
}
if _, err := tx.Exec(
`INSERT INTO domains (id, tenant_id, domain, active, accept_all, dkim_selector, dkim_private_key_enc) VALUES (?, ?, ?, 1, 1, ?, ?)`,
domainID, tenantID, hostname, dkimSelector, dkimKeyEnc,
); err != nil {
return fmt.Errorf("creating default domain: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(initPassword), bcryptCost)
if err != nil {
return fmt.Errorf("hashing admin password: %w", err)
}
adminEmail := "admin@" + hostname
if _, err := tx.Exec(
`INSERT INTO users (id, tenant_id, domain_id, email, password_hash, display_name, role, active)
VALUES (?, ?, ?, ?, ?, ?, 'global_admin', 1)`,
uuid.NewString(), tenantID, domainID, adminEmail, string(hash), "Global Admin",
); err != nil {
return fmt.Errorf("creating admin user: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing bootstrap: %w", err)
}
slog.Info("bootstrap complete", "admin_email", adminEmail, "tenant", "default", "domain", hostname)
return nil
}
+158
View File
@@ -0,0 +1,158 @@
// Package db wraps database/sql with the gomail schema. No ORM — raw SQL with
// prepared statements only, per the project's minimal-dependency principle.
package db
import (
"database/sql"
"fmt"
"log/slog"
"strings"
_ "github.com/mattn/go-sqlite3"
)
// DB wraps *sql.DB with the driver name (some queries need driver-specific SQL,
// e.g. placeholder syntax differs between sqlite/postgres/mysql).
type DB struct {
*sql.DB
Driver string
}
// Open connects to the database using the configured driver.
// SQLite is always available; postgres and mysql require build tags:
//
// go build -tags postgres .
// go build -tags mysql .
func Open(driver, dsn string) (*DB, error) {
d := strings.ToLower(driver)
var sqlDriverName string
switch d {
case "sqlite", "":
sqlDriverName = "sqlite3"
d = "sqlite"
default:
name, ok := driverRegistry[d]
if !ok {
available := []string{"sqlite"}
for k := range driverRegistry {
available = append(available, k)
}
return nil, fmt.Errorf("driver %q not compiled in; rebuild with -tags %s. Available: %v", driver, driver, available)
}
sqlDriverName = name
}
sqlDB, err := sql.Open(sqlDriverName, dsn)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
if d == "sqlite" {
// SQLite doesn't handle concurrent writers well — serialize via single conn.
sqlDB.SetMaxOpenConns(1)
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("enabling WAL mode: %w", err)
}
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
return nil, fmt.Errorf("enabling foreign keys: %w", err)
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
return nil, fmt.Errorf("setting busy timeout: %w", err)
}
}
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{DB: sqlDB, Driver: d}, nil
}
// driverRegistry is populated by build-tag-gated driver_*.go files
// (driver_postgres.go, driver_mysql.go) via init().
var driverRegistry = map[string]string{}
func registerDriver(name, sqlDriverName string) {
driverRegistry[strings.ToLower(name)] = sqlDriverName
}
// Migrate runs all pending schema migrations in order. Migrations are
// idempotent (CREATE TABLE IF NOT EXISTS) so this is always safe to call at
// startup.
func (db *DB) Migrate() error {
slog.Info("running database migrations")
if _, err := db.Exec(migrationsTableSQL[db.Driver]); err != nil {
return fmt.Errorf("creating migrations table: %w", err)
}
for _, m := range migrations {
applied, err := db.migrationApplied(m.name)
if err != nil {
return fmt.Errorf("checking migration %s: %w", m.name, err)
}
if applied {
continue
}
stmt := m.sql[db.Driver]
if stmt == "" {
stmt = m.sql["sqlite"] // fall back — most DDL is portable enough via driver quirks handled per-migration
}
slog.Info("applying migration", "name", m.name)
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", m.name, err)
}
if _, err := tx.Exec(stmt); err != nil {
tx.Rollback()
return fmt.Errorf("applying migration %s: %w", m.name, err)
}
if _, err := tx.Exec(db.insertMigrationSQL(), m.name); err != nil {
tx.Rollback()
return fmt.Errorf("recording migration %s: %w", m.name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", m.name, err)
}
}
slog.Info("migrations complete", "count", len(migrations))
return nil
}
func (db *DB) migrationApplied(name string) (bool, error) {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE name = "+db.placeholder(1), name).Scan(&count)
return count > 0, err
}
func (db *DB) insertMigrationSQL() string {
return "INSERT INTO schema_migrations (name, applied_at) VALUES (" + db.placeholder(1) + ", CURRENT_TIMESTAMP)"
}
// placeholder returns the driver-appropriate positional parameter syntax.
// sqlite/mysql use "?", postgres uses "$1", "$2", ...
func (db *DB) placeholder(n int) string {
if db.Driver == "postgres" {
return fmt.Sprintf("$%d", n)
}
return "?"
}
var migrationsTableSQL = map[string]string{
"sqlite": `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at DATETIME NOT NULL
)`,
"postgres": `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL
)`,
"mysql": `CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at DATETIME NOT NULL
)`,
}
+374
View File
@@ -0,0 +1,374 @@
package db
// migration is a single named schema change with driver-specific SQL variants.
// SQLite is the reference dialect (required); postgres/mysql variants are
// filled in as those build-tagged drivers are added — until then the sqlite
// SQL is close enough to run in most cases (TEXT/BLOB/DATETIME map cleanly).
type migration struct {
name string
sql map[string]string
}
var migrations = []migration{
{
name: "0001_tenants_domains",
sql: map[string]string{
"sqlite": `
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
display_name TEXT,
digest_interval_mins INTEGER NOT NULL DEFAULT 60,
max_accounts INTEGER NOT NULL DEFAULT 0,
quota_mb_per_user INTEGER NOT NULL DEFAULT 2048,
settings_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE domains (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
domain TEXT UNIQUE NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
dkim_selector TEXT,
dkim_private_key_enc BLOB,
accept_all INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_domains_tenant ON domains(tenant_id);
`,
},
},
{
name: "0002_users_auth",
sql: map[string]string{
"sqlite": `
CREATE TABLE users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
role TEXT NOT NULL DEFAULT 'user',
active INTEGER NOT NULL DEFAULT 1,
mfa_enabled INTEGER NOT NULL DEFAULT 0,
totp_secret_enc BLOB,
passkey_credentials_json TEXT NOT NULL DEFAULT '[]',
quota_mb INTEGER NOT NULL DEFAULT 2048,
used_bytes INTEGER NOT NULL DEFAULT 0,
digest_enabled INTEGER NOT NULL DEFAULT 1,
digest_interval_mins INTEGER NOT NULL DEFAULT 0,
last_digest_at DATETIME,
last_login_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_users_domain ON users(domain_id);
CREATE TABLE app_passwords (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
password_hash TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT 'smtp,imap',
last_used_at DATETIME,
expires_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_app_passwords_user ON app_passwords(user_id);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
jti TEXT UNIQUE NOT NULL,
user_agent TEXT,
ip TEXT,
expires_at DATETIME NOT NULL,
revoked_at DATETIME
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_jti ON sessions(jti);
CREATE TABLE aliases (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
from_address TEXT UNIQUE NOT NULL,
to_user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
to_external TEXT,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_aliases_tenant ON aliases(tenant_id);
`,
},
},
{
name: "0003_list_rules",
sql: map[string]string{
"sqlite": `
CREATE TABLE list_rules (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
list_type TEXT NOT NULL,
match_type TEXT NOT NULL DEFAULT 'email',
value TEXT NOT NULL,
note TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_list_rules_tenant ON list_rules(tenant_id);
CREATE INDEX idx_list_rules_value ON list_rules(value);
`,
},
},
{
name: "0004_messages_mailbox",
sql: map[string]string{
"sqlite": `
CREATE TABLE messages (
id TEXT PRIMARY KEY,
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
from_address TEXT NOT NULL,
to_address TEXT NOT NULL,
subject TEXT,
message_id_hdr TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
verdict TEXT NOT NULL DEFAULT 'clean',
total_score REAL NOT NULL DEFAULT 0,
sender_ip TEXT,
relayed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_messages_tenant ON messages(tenant_id);
CREATE INDEX idx_messages_to ON messages(to_address);
CREATE INDEX idx_messages_created ON messages(created_at);
CREATE TABLE message_checks (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
stage TEXT NOT NULL,
result TEXT NOT NULL,
score REAL NOT NULL DEFAULT 0,
detail TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_message_checks_message ON message_checks(message_id);
CREATE TABLE mailbox_index (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox TEXT NOT NULL DEFAULT 'INBOX',
uid INTEGER NOT NULL,
eml_path TEXT NOT NULL,
flags TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
internal_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, mailbox, uid)
);
CREATE INDEX idx_mailbox_index_user ON mailbox_index(user_id, mailbox);
CREATE TABLE mailbox_uid_counters (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox TEXT NOT NULL,
next_uid INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, mailbox)
);
`,
},
},
{
name: "0005_outbound_queue",
sql: map[string]string{
"sqlite": `
CREATE TABLE outbound_queue (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
from_address TEXT NOT NULL,
to_address TEXT NOT NULL,
eml_path TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_outbound_queue_next ON outbound_queue(next_attempt_at);
CREATE INDEX idx_outbound_queue_user ON outbound_queue(user_id);
`,
},
},
{
name: "0006_quarantine",
sql: map[string]string{
"sqlite": `
CREATE TABLE quarantine (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
eml_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'held',
reason TEXT,
released_by TEXT,
released_at DATETIME,
expires_at DATETIME NOT NULL,
notified_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_quarantine_status ON quarantine(status);
CREATE INDEX idx_quarantine_message ON quarantine(message_id);
CREATE TABLE release_tokens (
id TEXT PRIMARY KEY,
quarantine_id TEXT NOT NULL REFERENCES quarantine(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
email TEXT,
used_at DATETIME,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_release_tokens_token ON release_tokens(token);
`,
},
},
{
name: "0007_linked_accounts",
sql: map[string]string{
"sqlite": `
CREATE TABLE linked_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
display_name TEXT,
email_address TEXT NOT NULL,
auth_type TEXT NOT NULL,
imap_host TEXT,
imap_port INTEGER,
imap_tls TEXT,
smtp_host TEXT,
smtp_port INTEGER,
smtp_tls TEXT,
credential_enc BLOB,
oauth_expires_at DATETIME,
sync_state TEXT,
cache_retention_days INTEGER,
last_sync_at DATETIME,
last_sync_error TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_linked_accounts_user ON linked_accounts(user_id);
`,
},
},
{
name: "0008_dav_contacts_calendars",
sql: map[string]string{
"sqlite": `
CREATE TABLE addressbooks (
id TEXT PRIMARY KEY,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
display_name TEXT,
description TEXT,
sync_token TEXT NOT NULL DEFAULT '1',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_addressbooks_owner ON addressbooks(owner_type, owner_id);
CREATE TABLE contacts (
id TEXT PRIMARY KEY,
addressbook_id TEXT NOT NULL REFERENCES addressbooks(id) ON DELETE CASCADE,
uid TEXT NOT NULL,
vcard_enc BLOB NOT NULL,
etag TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(addressbook_id, uid)
);
CREATE INDEX idx_contacts_addressbook ON contacts(addressbook_id);
CREATE TABLE calendars (
id TEXT PRIMARY KEY,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
display_name TEXT,
description TEXT,
color TEXT,
timezone TEXT NOT NULL DEFAULT 'UTC',
sync_token TEXT NOT NULL DEFAULT '1',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_calendars_owner ON calendars(owner_type, owner_id);
CREATE TABLE calendar_objects (
id TEXT PRIMARY KEY,
calendar_id TEXT NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
uid TEXT NOT NULL,
ical_enc BLOB NOT NULL,
component_type TEXT,
summary TEXT,
dtstart DATETIME,
dtend DATETIME,
etag TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(calendar_id, uid)
);
CREATE INDEX idx_calendar_objects_calendar ON calendar_objects(calendar_id);
`,
},
},
{
name: "0009_sieve_scripts",
sql: map[string]string{
"sqlite": `
CREATE TABLE sieve_scripts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
script_text TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, name)
);
CREATE INDEX idx_sieve_scripts_user ON sieve_scripts(user_id);
`,
},
},
{
name: "0010_tls_certs",
sql: map[string]string{
"sqlite": `
CREATE TABLE tls_certs (
id TEXT PRIMARY KEY,
domain TEXT UNIQUE NOT NULL,
cert_pem_enc BLOB,
key_pem_enc BLOB,
expires_at DATETIME,
acme_account_key_enc BLOB,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tls_certs_domain ON tls_certs(domain);
`,
},
},
{
name: "0011_mfa_and_recovery",
sql: map[string]string{
"sqlite": `
CREATE TABLE mfa_backup_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_mfa_backup_codes_user ON mfa_backup_codes(user_id);
ALTER TABLE users ADD COLUMN recovery_email TEXT;
`,
},
},
}
+353
View File
@@ -0,0 +1,353 @@
package db
import "time"
// ── Tenants & domains ──────────────────────────────────────────────────────────
type Tenant struct {
ID string
Name string
DisplayName string
DigestIntervalMins int
MaxAccounts int // 0 = unlimited
QuotaMBPerUser int
SettingsJSON string // pipeline thresholds, check toggles (parsed by pipeline pkg)
CreatedAt time.Time
}
type Domain struct {
ID string
TenantID string
Domain string
Active bool
DKIMSelector string
DKIMPrivateKeyEnc []byte // AES-256-GCM encrypted PEM
AcceptAll bool
CreatedAt time.Time
}
// ── Users & auth ──────────────────────────────────────────────────────────────
type UserRole string
const (
RoleUser UserRole = "user"
RoleTenantAdmin UserRole = "tenant_admin"
RoleGlobalAdmin UserRole = "global_admin"
)
type User struct {
ID string
TenantID string
DomainID string
Email string
PasswordHash string
DisplayName string
Role UserRole
Active bool
MFAEnabled bool
TOTPSecretEnc []byte // AES-256-GCM encrypted
PasskeyCredentialsJSON string // JSON array of WebAuthn credentials
RecoveryEmail string // external address for password-reset delivery (see Phase 12 notes)
QuotaMB int
UsedBytes int64
DigestEnabled bool
DigestIntervalMins int // 0 = use tenant default
LastDigestAt *time.Time
LastLoginAt *time.Time
CreatedAt time.Time
}
type AppPassword struct {
ID string
UserID string
Label string
PasswordHash string // bcrypt of a 32-char random token
Scopes string // comma-separated: smtp,imap,caldav,carddav,pop3
LastUsedAt *time.Time
ExpiresAt *time.Time // nil = never expires
CreatedAt time.Time
}
type Session struct {
ID string
UserID string
JTI string // JWT ID, for revocation lookups
UserAgent string
IP string
ExpiresAt time.Time
RevokedAt *time.Time
}
type Alias struct {
ID string
TenantID string
FromAddress string
ToUserID *string // nil if forwarding externally
ToExternal *string // nil if local
Active bool
}
// ── List rules (allow/block, per tenant) ───────────────────────────────────────
type ListRuleAction string
const (
ListActionAllow ListRuleAction = "allow"
ListActionBlock ListRuleAction = "block"
)
type ListRule struct {
ID string
TenantID string
ListType ListRuleAction // allow | block
MatchType string // email | domain
Value string
Note string
Active bool
CreatedAt time.Time
}
// ── Messages (audit log) & mailbox index ────────────────────────────────────────
type MessageVerdict string
const (
VerdictClean MessageVerdict = "clean"
VerdictFlagged MessageVerdict = "flagged"
VerdictQuarantine MessageVerdict = "quarantine"
VerdictBlocked MessageVerdict = "blocked"
)
type Message struct {
ID string
TenantID string
FromAddress string
ToAddress string
Subject string
MessageIDHdr string
SizeBytes int64
Verdict MessageVerdict
TotalScore float64
SenderIP string
RelayedAt *time.Time
CreatedAt time.Time
}
type MailboxEntry struct {
ID string
UserID string
Mailbox string // INBOX, Sent, Trash, Junk, custom...
UID int
EMLPath string // path to encrypted .eml.enc on disk
Flags string // \Seen \Flagged \Answered \Deleted \Draft
SizeBytes int64
ReceivedAt time.Time
InternalDate time.Time
}
// ── Outbound queue ───────────────────────────────────────────────────────────
type OutboundQueueEntry struct {
ID string
UserID string
FromAddress string
ToAddress string
EMLPath string
Priority int
Attempts int
LastError string
NextAttemptAt time.Time
CreatedAt time.Time
}
// ── Pipeline check results ──────────────────────────────────────────────────────
// CheckResult is the outcome of a single pipeline stage (SPF, DKIM, etc.).
type CheckResult string
const (
CheckPass CheckResult = "pass"
CheckWarn CheckResult = "warn"
CheckFail CheckResult = "fail"
CheckSkipped CheckResult = "skipped"
CheckError CheckResult = "error"
)
type MessageCheck struct {
ID string
MessageID string
Stage string
Result CheckResult
Score float64
Detail string
DurationMs int64
}
// ── Quarantine ────────────────────────────────────────────────────────────────
type QuarantineStatus string
const (
QuarantineHeld QuarantineStatus = "held"
QuarantineReleased QuarantineStatus = "released"
QuarantineDeleted QuarantineStatus = "deleted"
)
type QuarantineEntry struct {
ID string
MessageID string
EMLPath string
Status QuarantineStatus
Reason string
ReleasedBy string
ReleasedAt *time.Time
ExpiresAt time.Time
NotifiedAt *time.Time
CreatedAt time.Time
}
type ReleaseToken struct {
ID string
QuarantineID string
Token string
Email string
UsedAt *time.Time
ExpiresAt time.Time
CreatedAt time.Time
}
// ── Linked accounts (multi-account webmail — Part B of the plan) ──────────────
type LinkedAccountProvider string
const (
ProviderGoMail LinkedAccountProvider = "gomail"
ProviderIMAP LinkedAccountProvider = "imap"
ProviderGmail LinkedAccountProvider = "gmail" // Phase 10
ProviderM365 LinkedAccountProvider = "m365" // Phase 10
)
type LinkedAccountAuthType string
const (
AuthTypeSession LinkedAccountAuthType = "session" // gomail local account, already logged in
AuthTypePassword LinkedAccountAuthType = "password" // generic IMAP/SMTP
AuthTypeOAuth2 LinkedAccountAuthType = "oauth2" // Phase 10
)
type LinkedAccount struct {
ID string
UserID string
Provider LinkedAccountProvider
DisplayName string
EmailAddress string
AuthType LinkedAccountAuthType
IMAPHost string
IMAPPort int
IMAPTLS string // "starttls" | "implicit" | "off"
SMTPHost string
SMTPPort int
SMTPTLS string
CredentialEnc []byte // encrypted password or OAuth2 tokens (JSON)
OAuthExpiresAt *time.Time
SyncState string
CacheRetentionDays int // 0 = use instance default
LastSyncAt *time.Time
LastSyncError string
Active bool
CreatedAt time.Time
}
// ── CalDAV / CardDAV ────────────────────────────────────────────────────────────
// OwnerType distinguishes a personal (per-user) collection from a shared
// tenant-wide one — both addressbooks and calendars support both scopes per
// the plan (tenant addressbook + per-user addressbook, same for calendars).
type OwnerType string
const (
OwnerUser OwnerType = "user"
OwnerTenant OwnerType = "tenant"
)
type Addressbook struct {
ID string
OwnerType OwnerType
OwnerID string
DisplayName string
Description string
SyncToken string
CreatedAt time.Time
}
type Contact struct {
ID string
AddressbookID string
UID string
VCardEnc []byte // AES-256-GCM encrypted vCard text
ETag string
CreatedAt time.Time
UpdatedAt time.Time
}
type Calendar struct {
ID string
OwnerType OwnerType
OwnerID string
DisplayName string
Description string
Color string
Timezone string
SyncToken string
CreatedAt time.Time
}
type CalendarObject struct {
ID string
CalendarID string
UID string
ICalEnc []byte // AES-256-GCM encrypted iCal text
ComponentType string // VEVENT | VTODO | VJOURNAL
Summary string
DTStart *time.Time
DTEnd *time.Time
ETag string
CreatedAt time.Time
UpdatedAt time.Time
}
// ── ManageSieve ───────────────────────────────────────────────────────────────
type SieveScript struct {
ID string
UserID string
Name string
ScriptText string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
// ── TLS certs (ACME) ────────────────────────────────────────────────────────────
type TLSCert struct {
ID string
Domain string
CertPEMEnc []byte
KeyPEMEnc []byte
ExpiresAt *time.Time
ACMEAccountKeyEnc []byte
CreatedAt time.Time
UpdatedAt time.Time
}
// ── MFA ───────────────────────────────────────────────────────────────────────
type MFABackupCode struct {
ID string
UserID string
CodeHash string
UsedAt *time.Time
CreatedAt time.Time
}
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
// Package dkim implements DKIM (RFC 6376) signing for outbound mail using
// only stdlib crypto — no third-party DKIM library. Verification of inbound
// DKIM signatures is added in Phase 4's security pipeline.
package dkim
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
)
// KeyPair holds a freshly generated DKIM signing key, both as PEM (for
// encrypted storage) and the DNS TXT record value the operator must publish.
type KeyPair struct {
PrivateKeyPEM []byte // PKCS#1 PEM — store encrypted in domains.dkim_private_key_enc
DNSRecordValue string // paste into: {selector}._domainkey.{domain} TXT record
}
// GenerateKeyPair creates a new RSA-2048 DKIM key pair. RSA-2048 is used
// (rather than Ed25519) because it has universal support across mail
// receivers — Ed25519 DKIM (RFC 8463) support is not yet ubiquitous.
func GenerateKeyPair() (*KeyPair, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generating RSA key: %w", err)
}
privDER := x509.MarshalPKCS1PrivateKey(priv)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privDER,
})
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshaling public key: %w", err)
}
pubB64 := base64.StdEncoding.EncodeToString(pubDER)
dnsValue := fmt.Sprintf("v=DKIM1; k=rsa; p=%s", pubB64)
return &KeyPair{
PrivateKeyPEM: privPEM,
DNSRecordValue: dnsValue,
}, nil
}
// ParsePrivateKey decodes a PEM-encoded RSA private key (as produced by
// GenerateKeyPair, after decryption from storage).
func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing RSA private key: %w", err)
}
return key, nil
}
// ExtractSignatureInfo pulls the signing domain and selector out of a
// message's DKIM-Signature header, without doing any verification — the
// caller uses this to know which DNS TXT record to fetch before calling
// Verify. Returns found=false if no DKIM-Signature header is present.
func ExtractSignatureInfo(raw []byte) (domain, selector string, found bool) {
headers, _ := splitMessage(raw)
headerMap := parseHeaders(headers)
sigHeader, ok := headerMap["dkim-signature"]
if !ok {
return "", "", false
}
tags := parseDKIMTags(sigHeader)
domain = tags["d"]
selector = tags["s"]
return domain, selector, domain != "" && selector != ""
}
// ParseDNSPublicKey decodes the "p=" tag value from a DKIM DNS TXT record
// (as published by GenerateKeyPair's DNSRecordValue, or any RFC 6376
// compliant record) into the raw public key DER bytes Verify expects.
func ParseDNSPublicKey(txtRecord string) ([]byte, error) {
tags := parseDKIMTags(txtRecord)
pValue, ok := tags["p"]
if !ok || pValue == "" {
return nil, fmt.Errorf("no p= tag found in DNS record")
}
return base64.StdEncoding.DecodeString(pValue)
}
+195
View File
@@ -0,0 +1,195 @@
package dkim
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"fmt"
"regexp"
"strings"
"time"
)
// signedHeaders is the fixed set of headers we sign, in order, when present.
// Keeping this list small and stable avoids the classic DKIM pitfall of
// signing headers that get legitimately rewritten in transit (Received, etc).
var signedHeaders = []string{"from", "to", "subject", "date", "message-id"}
// Sign adds a DKIM-Signature header to raw using relaxed/relaxed
// canonicalization and RSA-SHA256, per RFC 6376. Returns the message with
// the DKIM-Signature header prepended.
func Sign(privateKeyPEM []byte, domain, selector string, raw []byte) ([]byte, error) {
key, err := ParsePrivateKey(privateKeyPEM)
if err != nil {
return nil, err
}
headers, body := splitMessage(raw)
bodyCanon := canonicalizeBodyRelaxed(body)
bodyHash := sha256.Sum256(bodyCanon)
bodyHashB64 := base64.StdEncoding.EncodeToString(bodyHash[:])
headerMap := parseHeaders(headers)
var presentSigned []string
for _, h := range signedHeaders {
if _, ok := headerMap[h]; ok {
presentSigned = append(presentSigned, h)
}
}
if len(presentSigned) == 0 {
return nil, fmt.Errorf("no signable headers present in message")
}
// Build the DKIM-Signature header with an empty b= tag first — this
// unsigned version is itself included (relaxed-canonicalized) in what we
// sign, per RFC 6376 §3.7.
dkimHeaderTemplate := buildDKIMHeader(domain, selector, presentSigned, bodyHashB64, "")
signInput := canonicalizeHeadersRelaxed(headerMap, presentSigned)
signInput = append(signInput, canonicalizeHeaderRelaxed("dkim-signature", dkimHeaderTemplate)...)
// Per spec, the DKIM-Signature header itself is canonicalized WITHOUT a
// trailing CRLF when it's the last (signed) header being hashed.
signInput = bytes.TrimSuffix(signInput, []byte("\r\n"))
hashed := sha256.Sum256(signInput)
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hashed[:])
if err != nil {
return nil, fmt.Errorf("signing: %w", err)
}
sigB64 := base64.StdEncoding.EncodeToString(signature)
finalHeader := buildDKIMHeader(domain, selector, presentSigned, bodyHashB64, sigB64)
var out bytes.Buffer
out.WriteString("DKIM-Signature: ")
out.WriteString(finalHeader)
out.WriteString("\r\n")
out.Write(headers)
out.Write(body)
return out.Bytes(), nil
}
func buildDKIMHeader(domain, selector string, signedHdrs []string, bodyHashB64, sigB64 string) string {
return fmt.Sprintf(
"v=1; a=rsa-sha256; c=relaxed/relaxed; d=%s; s=%s; t=%d; h=%s; bh=%s; b=%s",
domain, selector, time.Now().Unix(), strings.Join(signedHdrs, ":"), bodyHashB64, sigB64,
)
}
// splitMessage separates the raw RFC 5322 message into its header block
// (including the trailing blank line's CRLF) and body.
func splitMessage(raw []byte) (headers, body []byte) {
sep := []byte("\r\n\r\n")
idx := bytes.Index(raw, sep)
if idx == -1 {
// Tolerate bare-LF input (shouldn't happen from our own DATA reader,
// which always produces CRLF, but be defensive).
sep = []byte("\n\n")
idx = bytes.Index(raw, sep)
if idx == -1 {
return raw, nil
}
}
return raw[:idx+len(sep)], raw[idx+len(sep):]
}
// parseHeaders builds a lowercase-name -> raw-value-with-original-case map,
// unfolding continuation lines (RFC 5322 §2.2.3).
func parseHeaders(headerBlock []byte) map[string]string {
result := map[string]string{}
lines := strings.Split(string(headerBlock), "\r\n")
var currentName, currentValue string
flush := func() {
if currentName != "" {
result[strings.ToLower(currentName)] = currentValue
}
}
for _, line := range lines {
if line == "" {
continue
}
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && currentName != "" {
currentValue += " " + strings.TrimSpace(line)
continue
}
flush()
name, value, found := strings.Cut(line, ":")
if !found {
currentName = ""
continue
}
currentName = strings.TrimSpace(name)
currentValue = strings.TrimSpace(value)
}
flush()
return result
}
// canonicalizeHeadersRelaxed builds the signed-header block per RFC 6376
// §3.4.2: lowercase header name, unfold, collapse WSP runs to single space,
// trim trailing WSP on the value, each header terminated with CRLF, in the
// exact order listed by names.
func canonicalizeHeadersRelaxed(headerMap map[string]string, names []string) []byte {
var buf bytes.Buffer
for _, name := range names {
value, ok := headerMap[name]
if !ok {
continue
}
buf.Write(canonicalizeHeaderRelaxed(name, value))
}
return buf.Bytes()
}
func canonicalizeHeaderRelaxed(name, value string) []byte {
name = strings.ToLower(strings.TrimSpace(name))
value = collapseWSP(strings.TrimSpace(value))
return []byte(name + ":" + value + "\r\n")
}
var wspRunRE = regexp.MustCompile(`[ \t]+`)
func collapseWSP(s string) string {
return wspRunRE.ReplaceAllString(s, " ")
}
// canonicalizeBodyRelaxed implements RFC 6376 §3.4.4: reduce WSP sequences
// within a line to a single space, remove trailing WSP from each line,
// remove trailing empty lines (but keep exactly one CRLF if the body is
// non-empty after trimming).
func canonicalizeBodyRelaxed(body []byte) []byte {
if len(body) == 0 {
return []byte("")
}
lines := bytes.Split(body, []byte("\r\n"))
for i, line := range lines {
line = wspRunRE.ReplaceAll(line, []byte(" "))
lines[i] = bytes.TrimRight(line, " \t")
}
// Remove trailing empty lines.
end := len(lines)
for end > 0 && len(lines[end-1]) == 0 {
end--
}
lines = lines[:end]
if len(lines) == 0 {
return []byte("")
}
var buf bytes.Buffer
for _, line := range lines {
buf.Write(line)
buf.WriteString("\r\n")
}
return buf.Bytes()
}
+108
View File
@@ -0,0 +1,108 @@
package dkim
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"fmt"
"strings"
)
// Verify checks a signed message's DKIM-Signature header against the given
// public key (as would be fetched from DNS in Phase 4's inbound pipeline).
// This lean version only handles rsa-sha256/relaxed-relaxed — the exact
// profile Sign() produces — since its purpose here is to prove the signer is
// correct. Phase 4 will build a fuller verifier (multiple algorithms,
// simple/simple and mixed canonicalization) for arbitrary inbound mail.
func Verify(publicKeyDER []byte, raw []byte) error {
headers, body := splitMessage(raw)
headerMap := parseHeaders(headers)
dkimHeaderValue, ok := headerMap["dkim-signature"]
if !ok {
return fmt.Errorf("no DKIM-Signature header present")
}
tags := parseDKIMTags(dkimHeaderValue)
if tags["a"] != "rsa-sha256" {
return fmt.Errorf("unsupported algorithm: %s", tags["a"])
}
if tags["c"] != "relaxed/relaxed" {
return fmt.Errorf("unsupported canonicalization: %s", tags["c"])
}
// Verify body hash.
bodyCanon := canonicalizeBodyRelaxed(body)
bodyHash := sha256.Sum256(bodyCanon)
expectedBH := base64.StdEncoding.EncodeToString(bodyHash[:])
if tags["bh"] != expectedBH {
return fmt.Errorf("body hash mismatch: signature claims %s, computed %s", tags["bh"], expectedBH)
}
signedHdrNames := strings.Split(tags["h"], ":")
// Rebuild the exact signing input: canonicalized signed headers, then the
// DKIM-Signature header itself with b= emptied, no trailing CRLF.
signInput := canonicalizeHeadersRelaxed(headerMap, signedHdrNames)
dkimHeaderNoB := replaceDKIMTag(dkimHeaderValue, "b", "")
signInput = append(signInput, canonicalizeHeaderRelaxed("dkim-signature", dkimHeaderNoB)...)
signInput = trimTrailingCRLF(signInput)
sigBytes, err := base64.StdEncoding.DecodeString(tags["b"])
if err != nil {
return fmt.Errorf("decoding signature: %w", err)
}
pubAny, err := x509.ParsePKIXPublicKey(publicKeyDER)
if err != nil {
return fmt.Errorf("parsing public key: %w", err)
}
pubKey, ok := pubAny.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("public key is not RSA")
}
hashed := sha256.Sum256(signInput)
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hashed[:], sigBytes); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
return nil
}
func parseDKIMTags(header string) map[string]string {
tags := map[string]string{}
for _, part := range strings.Split(header, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name, value, found := strings.Cut(part, "=")
if !found {
continue
}
tags[strings.TrimSpace(name)] = strings.TrimSpace(value)
}
return tags
}
func replaceDKIMTag(header, tag, newValue string) string {
parts := strings.Split(header, ";")
for i, part := range parts {
trimmed := strings.TrimSpace(part)
if strings.HasPrefix(trimmed, tag+"=") {
parts[i] = " " + tag + "=" + newValue
}
}
return strings.Join(parts, ";")
}
func trimTrailingCRLF(b []byte) []byte {
for len(b) >= 2 && b[len(b)-2] == '\r' && b[len(b)-1] == '\n' {
return b[:len(b)-2]
}
return b
}
+150
View File
@@ -0,0 +1,150 @@
// Package ical implements a minimal RFC 5545 iCalendar parser/builder — just
// VEVENT with the fields CalDAV needs: UID, SUMMARY, DTSTART, DTEND,
// DESCRIPTION, LOCATION. Not full RFC 5545 (no VTODO/VJOURNAL/VALARM, no
// RRULE recurrence) — enough for real calendar clients to create, fetch, and
// list single events, with recurrence and other component types as natural
// next additions once client compatibility testing calls for them.
package ical
import (
"fmt"
"strings"
"time"
)
const icalTimeLayout = "20060102T150405Z"
type Event struct {
UID string
Summary string
Description string
Location string
DTStart time.Time
DTEnd time.Time
}
// Parse reads a VCALENDAR containing one VEVENT.
func Parse(data string) (*Event, error) {
lines := unfold(data)
e := &Event{}
inEvent := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
upper := strings.ToUpper(line)
switch {
case upper == "BEGIN:VEVENT":
inEvent = true
continue
case upper == "END:VEVENT":
inEvent = false
continue
}
if !inEvent {
continue
}
name, value, found := splitProperty(line)
if !found {
continue
}
switch strings.ToUpper(name) {
case "UID":
e.UID = value
case "SUMMARY":
e.Summary = unescape(value)
case "DESCRIPTION":
e.Description = unescape(value)
case "LOCATION":
e.Location = unescape(value)
case "DTSTART":
if t, err := time.Parse(icalTimeLayout, value); err == nil {
e.DTStart = t
}
case "DTEND":
if t, err := time.Parse(icalTimeLayout, value); err == nil {
e.DTEnd = t
}
}
}
if e.UID == "" {
return nil, fmt.Errorf("ical missing required UID property")
}
return e, nil
}
// Build renders an Event back into a full VCALENDAR/VEVENT block, CRLF line
// endings per spec.
func (e *Event) Build() string {
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("PRODID:-//GoMail//CalDAV//EN\r\n")
b.WriteString("BEGIN:VEVENT\r\n")
b.WriteString("UID:" + e.UID + "\r\n")
if !e.DTStart.IsZero() {
b.WriteString("DTSTART:" + e.DTStart.UTC().Format(icalTimeLayout) + "\r\n")
}
if !e.DTEnd.IsZero() {
b.WriteString("DTEND:" + e.DTEnd.UTC().Format(icalTimeLayout) + "\r\n")
}
if e.Summary != "" {
b.WriteString("SUMMARY:" + escape(e.Summary) + "\r\n")
}
if e.Description != "" {
b.WriteString("DESCRIPTION:" + escape(e.Description) + "\r\n")
}
if e.Location != "" {
b.WriteString("LOCATION:" + escape(e.Location) + "\r\n")
}
b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n")
return b.String()
}
func splitProperty(line string) (name, value string, found bool) {
colonIdx := strings.Index(line, ":")
if colonIdx == -1 {
return "", "", false
}
namePart := line[:colonIdx]
value = line[colonIdx+1:]
if semiIdx := strings.Index(namePart, ";"); semiIdx != -1 {
namePart = namePart[:semiIdx]
}
return namePart, value, true
}
// unfold reverses RFC 5545 §3.1 line folding, same rule as vCard's.
func unfold(data string) []string {
raw := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n")
var out []string
for _, line := range raw {
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 {
out[len(out)-1] += line[1:]
} else {
out = append(out, line)
}
}
return out
}
func escape(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, "\n", "\\n")
return s
}
func unescape(s string) string {
s = strings.ReplaceAll(s, "\\n", "\n")
s = strings.ReplaceAll(s, "\\,", ",")
s = strings.ReplaceAll(s, "\\;", ";")
s = strings.ReplaceAll(s, "\\\\", "\\")
return s
}
+23
View File
@@ -0,0 +1,23 @@
package ical
import "testing"
func FuzzParse(f *testing.F) {
f.Add("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test-1\r\nDTSTART:20260101T120000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
f.Add("BEGIN:VEVENT\nUID:no-crlf\nEND:VEVENT\n")
f.Add("BEGIN:VEVENT\r\nUID:folded\r\nDESCRIPTION:line one\r\n continued\r\nEND:VEVENT\r\n")
f.Add("")
f.Add("BEGIN:VEVENT\r\nEND:VEVENT\r\n")
f.Add("not an ical at all")
f.Add("BEGIN:VEVENT\r\nDTSTART:not-a-real-date\r\nUID:x\r\nEND:VEVENT\r\n")
f.Add("BEGIN:VEVENT\r\n:\r\nUID:x\r\nEND:VEVENT\r\n")
f.Fuzz(func(t *testing.T, data string) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("Parse panicked on input %q: %v", data, r)
}
}()
Parse(data)
})
}
+518
View File
@@ -0,0 +1,518 @@
package imap
import (
"fmt"
"strconv"
"strings"
"gomail/internal/db"
)
func (s *session) cmdCapability(tag string) {
caps := "CAPABILITY IMAP4rev1"
if !s.tlsActive {
caps += " STARTTLS LOGINDISABLED"
} else {
caps += " AUTH=LOGIN"
}
s.untagged(caps)
s.tagged(tag, "OK CAPABILITY completed")
}
func (s *session) cmdStartTLS(tag string) {
if s.tlsActive {
s.tagged(tag, "BAD TLS already active")
return
}
s.tagged(tag, "OK begin TLS negotiation now")
if err := s.upgradeTLS(s.server.tlsConf); err != nil {
return // connection is likely unusable now; caller's read loop will error out and close
}
s.tlsActive = true
}
func (s *session) cmdLogin(tag string, args []string) {
if !s.tlsActive {
s.tagged(tag, "NO LOGIN over plaintext refused — use STARTTLS or connect on the implicit-TLS port")
return
}
// Checked before attempting any credential verification — same
// rationale as smtp.session.handleAuth's authLimiter check.
ip := connHost(s.conn.RemoteAddr())
if !s.server.authLimiter.Allow(ip) {
s.tagged(tag, "NO too many authentication attempts, try again later")
return
}
if len(args) < 2 {
s.tagged(tag, "BAD LOGIN requires username and password")
return
}
username, password := args[0], args[1]
if !s.authenticateUser(username, password) {
s.tagged(tag, "NO LOGIN failed")
return
}
s.tagged(tag, "OK LOGIN completed")
}
func (s *session) cmdSelectExamine(tag string, args []string, readWrite bool) {
if !s.requireAuthenticated(tag) {
return
}
if len(args) < 1 {
s.tagged(tag, "BAD SELECT/EXAMINE requires a mailbox name")
return
}
mailbox := args[0]
entries, err := s.server.database.ListMailboxEntries(s.user.ID, mailbox)
if err != nil {
s.tagged(tag, "NO SELECT failed: "+err.Error())
return
}
s.mailbox = mailbox
s.entries = entries
s.readOnly = !readWrite
s.state = stateSelected
unseen := 0
nextUID := 1
for i, e := range entries {
if !strings.Contains(e.Flags, "\\Seen") && unseen == 0 {
s.untagged(fmt.Sprintf("OK [UNSEEN %d] first unseen", i+1))
unseen = i + 1
}
if e.UID >= nextUID {
nextUID = e.UID + 1
}
}
s.untagged(fmt.Sprintf("%d EXISTS", len(entries)))
s.untagged("0 RECENT")
s.untagged("FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)")
s.untagged("OK [PERMANENTFLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)] Limited")
s.untagged("OK [UIDVALIDITY 1] UIDs valid")
s.untagged(fmt.Sprintf("OK [UIDNEXT %d] Predicted next UID", nextUID))
if readWrite {
s.tagged(tag, "OK [READ-WRITE] SELECT completed")
} else {
s.tagged(tag, "OK [READ-ONLY] EXAMINE completed")
}
}
func (s *session) cmdList(tag string, args []string) {
if !s.requireAuthenticated(tag) {
return
}
// args: reference-name mailbox-pattern — we ignore hierarchy and just
// list every mailbox the user has, since GoMail's folder model is flat
// (no nested folders yet). A "%"/"*" wildcard pattern matches everything
// in this simplified model.
names, err := s.server.database.ListMailboxNames(s.user.ID)
if err != nil {
s.tagged(tag, "NO LIST failed: "+err.Error())
return
}
for _, name := range names {
s.untagged(fmt.Sprintf(`LIST () "/" %s`, quoteIfNeeded(name)))
}
s.tagged(tag, "OK LIST completed")
}
func (s *session) cmdClose(tag string) {
if !s.requireSelected(tag) {
return
}
s.expungeDeleted()
s.mailbox = ""
s.entries = nil
s.state = stateAuthenticated
s.tagged(tag, "OK CLOSE completed")
}
func (s *session) cmdExpunge(tag string) {
if !s.requireSelected(tag) {
return
}
if s.readOnly {
s.tagged(tag, "NO mailbox is read-only")
return
}
removed := s.expungeDeleted()
s.tagged(tag, fmt.Sprintf("OK EXPUNGE completed (%d removed)", removed))
}
// expungeDeleted removes every \Deleted-flagged message from storage and the
// index, sends the required untagged "N EXPUNGE" responses (in descending
// sequence order, per RFC 3501 §6.4.3 — removing from the end first keeps
// earlier sequence numbers stable for any remaining EXPUNGE responses in the
// same batch), and refreshes the in-memory snapshot.
func (s *session) expungeDeleted() int {
var kept []db.MailboxEntry
var removedSeqs []int
for i, e := range s.entries {
if strings.Contains(e.Flags, "\\Deleted") {
removedSeqs = append(removedSeqs, i+1)
s.server.database.DeleteMailboxEntry(e.ID)
// Best-effort file removal — the DB row is the source of truth for
// "does this message exist"; a leftover encrypted file with no
// index row is inert.
} else {
kept = append(kept, e)
}
}
for i := len(removedSeqs) - 1; i >= 0; i-- {
s.untagged(fmt.Sprintf("%d EXPUNGE", removedSeqs[i]))
}
s.entries = kept
return len(removedSeqs)
}
func (s *session) cmdUID(tag string, args []string) {
if len(args) < 1 {
s.tagged(tag, "BAD UID requires a subcommand")
return
}
sub := strings.ToUpper(args[0])
rest := args[1:]
switch sub {
case "FETCH":
s.cmdFetch(tag, rest, true)
case "STORE":
s.cmdStore(tag, rest, true)
case "SEARCH":
s.cmdSearch(tag, rest, true)
default:
s.tagged(tag, "BAD UID subcommand not recognized")
}
}
// ── FETCH ─────────────────────────────────────────────────────────────────────
func (s *session) cmdFetch(tag string, args []string, byUID bool) {
if !s.requireSelected(tag) {
return
}
if len(args) < 2 {
s.tagged(tag, "BAD FETCH requires a sequence-set and item list")
return
}
targets := s.resolveSequenceSet(args[0], byUID)
items := expandFetchItems(args[1])
for _, idx := range targets {
entry := s.entries[idx]
s.sendFetchResponse(idx+1, entry, items)
}
s.tagged(tag, "OK FETCH completed")
}
func expandFetchItems(token string) []string {
var items []string
if isList(token) {
items = splitList(token)
} else {
items = []string{token}
}
var expanded []string
for _, item := range items {
switch strings.ToUpper(item) {
case "FAST":
expanded = append(expanded, "FLAGS", "INTERNALDATE", "RFC822.SIZE")
case "ALL":
expanded = append(expanded, "FLAGS", "INTERNALDATE", "RFC822.SIZE")
case "FULL":
expanded = append(expanded, "FLAGS", "INTERNALDATE", "RFC822.SIZE", "BODY[]")
default:
expanded = append(expanded, item)
}
}
return expanded
}
func (s *session) sendFetchResponse(seq int, entry db.MailboxEntry, items []string) {
var parts []string
markSeen := false
for _, item := range items {
upper := strings.ToUpper(item)
switch {
case upper == "FLAGS":
parts = append(parts, "FLAGS ("+flagsToIMAP(entry.Flags)+")")
case upper == "UID":
parts = append(parts, fmt.Sprintf("UID %d", entry.UID))
case upper == "RFC822.SIZE":
parts = append(parts, fmt.Sprintf("RFC822.SIZE %d", entry.SizeBytes))
case upper == "INTERNALDATE":
parts = append(parts, fmt.Sprintf(`INTERNALDATE "%s"`, entry.InternalDate.Format("02-Jan-2006 15:04:05 -0700")))
case upper == "BODY[]" || upper == "RFC822":
raw, err := s.server.store.Read(entry.EMLPath)
if err == nil {
parts = append(parts, fmt.Sprintf("BODY[] {%d}\r\n%s", len(raw), raw))
markSeen = true
}
case upper == "BODY.PEEK[]":
raw, err := s.server.store.Read(entry.EMLPath)
if err == nil {
parts = append(parts, fmt.Sprintf("BODY[] {%d}\r\n%s", len(raw), raw))
}
case upper == "BODY[HEADER]" || upper == "RFC822.HEADER" || upper == "BODY.PEEK[HEADER]":
raw, err := s.server.store.Read(entry.EMLPath)
if err == nil {
headers := extractHeaders(raw)
parts = append(parts, fmt.Sprintf("BODY[HEADER] {%d}\r\n%s", len(headers), headers))
if upper == "RFC822.HEADER" {
markSeen = true
}
}
}
}
if markSeen && !strings.Contains(entry.Flags, "\\Seen") {
newFlags := addFlag(entry.Flags, "\\Seen")
s.server.database.UpdateMailboxFlags(entry.ID, newFlags)
for i := range s.entries {
if s.entries[i].ID == entry.ID {
s.entries[i].Flags = newFlags
}
}
}
s.untagged(fmt.Sprintf("%d FETCH (%s)", seq, strings.Join(parts, " ")))
}
func extractHeaders(raw []byte) []byte {
sep := []byte("\r\n\r\n")
if idx := indexOf(raw, sep); idx >= 0 {
return raw[:idx+2]
}
return raw
}
func indexOf(haystack, needle []byte) int {
for i := 0; i+len(needle) <= len(haystack); i++ {
match := true
for j := range needle {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
// ── STORE ─────────────────────────────────────────────────────────────────────
func (s *session) cmdStore(tag string, args []string, byUID bool) {
if !s.requireSelected(tag) {
return
}
if s.readOnly {
s.tagged(tag, "NO mailbox is read-only")
return
}
if len(args) < 3 {
s.tagged(tag, "BAD STORE requires sequence-set, item, and flag list")
return
}
targets := s.resolveSequenceSet(args[0], byUID)
action := strings.ToUpper(args[1])
newFlags := splitList(args[2])
if len(newFlags) == 0 {
newFlags = args[2:]
}
silent := strings.Contains(action, ".SILENT")
for _, idx := range targets {
entry := &s.entries[idx]
switch {
case strings.HasPrefix(action, "+FLAGS"):
for _, f := range newFlags {
entry.Flags = addFlag(entry.Flags, f)
}
case strings.HasPrefix(action, "-FLAGS"):
for _, f := range newFlags {
entry.Flags = removeFlag(entry.Flags, f)
}
case strings.HasPrefix(action, "FLAGS"):
entry.Flags = strings.Join(newFlags, " ")
default:
continue
}
s.server.database.UpdateMailboxFlags(entry.ID, entry.Flags)
if !silent {
s.untagged(fmt.Sprintf("%d FETCH (FLAGS (%s))", idx+1, flagsToIMAP(entry.Flags)))
}
}
s.tagged(tag, "OK STORE completed")
}
func addFlag(flags, flag string) string {
if strings.Contains(flags, flag) {
return flags
}
if flags == "" {
return flag
}
return flags + " " + flag
}
func removeFlag(flags, flag string) string {
parts := strings.Fields(flags)
var out []string
for _, p := range parts {
if p != flag {
out = append(out, p)
}
}
return strings.Join(out, " ")
}
func flagsToIMAP(flags string) string {
return flags // stored representation already matches IMAP flag syntax
}
// ── SEARCH ────────────────────────────────────────────────────────────────────
func (s *session) cmdSearch(tag string, args []string, byUID bool) {
if !s.requireSelected(tag) {
return
}
if len(args) == 0 {
s.tagged(tag, "BAD SEARCH requires criteria")
return
}
var matches []int
for i, entry := range s.entries {
if matchesSearch(entry, args) {
if byUID {
matches = append(matches, entry.UID)
} else {
matches = append(matches, i+1)
}
}
}
strs := make([]string, len(matches))
for i, m := range matches {
strs[i] = strconv.Itoa(m)
}
s.untagged("SEARCH " + strings.Join(strs, " "))
s.tagged(tag, "OK SEARCH completed")
}
// matchesSearch supports a pragmatic subset: ALL, UNSEEN, SEEN, ANSWERED,
// DELETED, FLAGGED, plus one-shot FROM/SUBJECT substring matching (checked
// against the flags string / a lightweight header scan). Full IMAP SEARCH
// grammar (nested boolean groups, date ranges, OR) is deferred.
func matchesSearch(entry db.MailboxEntry, criteria []string) bool {
for i := 0; i < len(criteria); i++ {
switch strings.ToUpper(criteria[i]) {
case "ALL":
continue
case "UNSEEN":
if strings.Contains(entry.Flags, "\\Seen") {
return false
}
case "SEEN":
if !strings.Contains(entry.Flags, "\\Seen") {
return false
}
case "ANSWERED":
if !strings.Contains(entry.Flags, "\\Answered") {
return false
}
case "DELETED":
if !strings.Contains(entry.Flags, "\\Deleted") {
return false
}
case "FLAGGED":
if !strings.Contains(entry.Flags, "\\Flagged") {
return false
}
}
}
return true
}
// ── Sequence set resolution ────────────────────────────────────────────────────
// resolveSequenceSet parses "1", "1:3", "1,3,5", "1:*" (sequence numbers) or
// the equivalent for UIDs when byUID is true, and returns 0-based indexes
// into s.entries.
func (s *session) resolveSequenceSet(spec string, byUID bool) []int {
var result []int
seen := map[int]bool{}
for _, part := range strings.Split(spec, ",") {
var lo, hi int
if strings.Contains(part, ":") {
bounds := strings.SplitN(part, ":", 2)
lo = parseSeqNum(bounds[0], byUID, s.entries)
hi = parseSeqNum(bounds[1], byUID, s.entries)
if lo > hi {
lo, hi = hi, lo
}
} else {
lo = parseSeqNum(part, byUID, s.entries)
hi = lo
}
for i, e := range s.entries {
var val int
if byUID {
val = e.UID
} else {
val = i + 1
}
if val >= lo && val <= hi && !seen[i] {
seen[i] = true
result = append(result, i)
}
}
}
return result
}
func parseSeqNum(s string, byUID bool, entries []db.MailboxEntry) int {
if s == "*" {
if len(entries) == 0 {
return 0
}
if byUID {
return entries[len(entries)-1].UID
}
return len(entries)
}
n, err := strconv.Atoi(s)
if err != nil {
return 0
}
return n
}
func quoteIfNeeded(name string) string {
if strings.ContainsAny(name, " \t()\"") {
return `"` + strings.ReplaceAll(name, `"`, `\"`) + `"`
}
return name
}
+77
View File
@@ -0,0 +1,77 @@
package imap
import "strings"
// tokenize splits an IMAP command line into space-separated tokens, treating
// "quoted strings" and (parenthesized lists) as single tokens (lists keep
// their outer parens so command handlers can recognize and further split
// them). Literal syntax ({n}\r\n<bytes>) is not handled here — see session.go's
// readCommand, which handles literals as a pre-pass before tokenizing since
// they require reading raw bytes off the connection, not just string scanning.
func tokenize(line string) []string {
var tokens []string
i, n := 0, len(line)
for i < n {
for i < n && (line[i] == ' ' || line[i] == '\t') {
i++
}
if i >= n {
break
}
switch line[i] {
case '"':
j := i + 1
var sb strings.Builder
for j < n && line[j] != '"' {
if line[j] == '\\' && j+1 < n {
j++
}
sb.WriteByte(line[j])
j++
}
tokens = append(tokens, sb.String())
i = j + 1
case '(':
depth := 1
j := i + 1
for j < n && depth > 0 {
switch line[j] {
case '(':
depth++
case ')':
depth--
}
j++
}
tokens = append(tokens, line[i:j])
i = j
default:
j := i
for j < n && line[j] != ' ' && line[j] != '\t' {
j++
}
tokens = append(tokens, line[i:j])
i = j
}
}
return tokens
}
// splitList takes a token like "(FLAGS UID)" and returns its inner
// space-separated items — used by FETCH/STORE argument parsing.
func splitList(token string) []string {
inner := strings.TrimPrefix(token, "(")
inner = strings.TrimSuffix(inner, ")")
if inner == "" {
return nil
}
return tokenize(inner)
}
func isList(token string) bool {
return strings.HasPrefix(token, "(") && strings.HasSuffix(token, ")")
}
+150
View File
@@ -0,0 +1,150 @@
// Package imap implements a hand-rolled IMAP server covering the core
// command set (RFC 3501/9051 essentials): CAPABILITY, LOGIN, LOGOUT, NOOP,
// SELECT/EXAMINE, LIST, FETCH, UID FETCH, STORE, UID STORE, SEARCH, EXPUNGE,
// CLOSE, UNSELECT. No third-party IMAP library — stdlib net.Listener plus a
// small hand-written parser for IMAP's atom/quoted-string/literal syntax.
//
// Deferred to a later pass (noted here so the gap is visible, not hidden):
// IDLE, CONDSTORE/QRESYNC, SORT/THREAD, and mailbox CREATE/DELETE/RENAME.
// The core set above is enough for read/flag/delete workflows against an
// existing mailbox, which covers most mail client usage; IDLE (push) and
// folder management are the natural next additions.
package imap
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net"
"sync"
"time"
"gomail/internal/db"
"gomail/internal/mailstore"
"gomail/internal/ratelimit"
)
const (
idleTimeout = 30 * time.Minute // IMAP clients often sit connected much longer than SMTP
maxCommandLine = 8192
)
type Server struct {
database *db.DB
store *mailstore.Store
tlsConf *tls.Config
hostname string
listeners []net.Listener
wg sync.WaitGroup
sessionWG sync.WaitGroup
connLimiter *ratelimit.Limiter // per-IP connections/min
authLimiter *ratelimit.Limiter // per-IP LOGIN failures/min — checked before credential verification
}
func NewServer(database *db.DB, store *mailstore.Store, tlsConf *tls.Config, hostname string, connPerMin, authFailuresPerMin int) *Server {
return &Server{
database: database,
store: store,
tlsConf: tlsConf,
hostname: hostname,
connLimiter: ratelimit.New(connPerMin),
authLimiter: ratelimit.New(authFailuresPerMin),
}
}
// ListenAndServe starts the plain (:143, STARTTLS-capable) and implicit-TLS
// (:993) listeners and blocks until ctx is cancelled or a listener fails.
func (s *Server) ListenAndServe(ctx context.Context, plainAddr, tlsAddr string) error {
specs := []struct {
addr string
useTLS bool
}{
{plainAddr, false},
{tlsAddr, true},
}
for _, spec := range specs {
ln, err := net.Listen("tcp", spec.addr)
if err != nil {
s.closeAll()
return fmt.Errorf("listen %s: %w", spec.addr, err)
}
if spec.useTLS {
ln = tls.NewListener(ln, s.tlsConf)
}
s.listeners = append(s.listeners, ln)
slog.Info("IMAP listener started", "addr", spec.addr, "implicit_tls", spec.useTLS)
s.wg.Add(1)
go func(ln net.Listener) {
defer s.wg.Done()
s.acceptLoop(ctx, ln)
}(ln)
}
<-ctx.Done()
return ctx.Err()
}
func (s *Server) acceptLoop(ctx context.Context, ln net.Listener) {
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
slog.Error("IMAP accept error", "err", err)
return
}
}
ip := connHost(conn.RemoteAddr())
if !s.connLimiter.Allow(ip) {
slog.Warn("IMAP connection rate limit exceeded, rejecting", "ip", ip)
conn.Close()
continue
}
s.sessionWG.Add(1)
go func() {
defer s.sessionWG.Done()
sess := newSession(conn, s)
sess.run(ctx)
}()
}
}
func (s *Server) Shutdown(gracePeriod time.Duration) {
s.closeAll()
done := make(chan struct{})
go func() {
s.sessionWG.Wait()
close(done)
}()
select {
case <-done:
slog.Info("all IMAP sessions drained cleanly")
case <-time.After(gracePeriod):
slog.Warn("IMAP shutdown grace period expired — some sessions forcibly terminated")
}
}
func (s *Server) closeAll() {
for _, ln := range s.listeners {
ln.Close()
}
s.wg.Wait()
}
// connHost extracts just the IP (no port) from a net.Addr, for use as a
// rate-limiter key.
func connHost(addr net.Addr) string {
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
+205
View File
@@ -0,0 +1,205 @@
package imap
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"log/slog"
"net"
"strings"
"time"
"gomail/internal/auth"
"gomail/internal/db"
)
type state int
const (
stateNotAuthenticated state = iota
stateAuthenticated
stateSelected
)
type session struct {
conn net.Conn
rw *bufio.ReadWriter
server *Server
state state
user *db.User
mailbox string
readOnly bool
tlsActive bool
// snapshot of the selected mailbox's contents at SELECT time — IMAP
// sequence numbers are defined against this snapshot, not a live query,
// per standard IMAP semantics (changes appear as untagged responses on
// the next command in a fuller implementation; this pass re-snapshots on
// every SELECT/EXAMINE, which is correct as long as the client
// re-selects to see new mail — IDLE for live push is a later addition).
entries []db.MailboxEntry
}
func newSession(conn net.Conn, server *Server) *session {
_, isTLS := conn.(*tls.Conn)
return &session{
conn: conn,
rw: bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)),
server: server,
state: stateNotAuthenticated,
tlsActive: isTLS,
}
}
func (s *session) run(ctx context.Context) {
s.untagged(fmt.Sprintf("OK %s GoMail IMAP4rev1 ready", s.server.hostname))
for {
select {
case <-ctx.Done():
s.untagged("BYE server shutting down")
return
default:
}
s.conn.SetReadDeadline(time.Now().Add(idleTimeout))
tag, cmd, args, err := s.readCommand()
if err != nil {
if err != io.EOF {
slog.Debug("IMAP read error", "err", err)
}
return
}
if !s.dispatch(tag, cmd, args) {
return
}
}
}
// readCommand reads one line and tokenizes it into (tag, command, args).
// Literal syntax is intentionally unsupported in this pass (see parser.go
// doc comment) — a line ending in {n} is treated as a parse error rather
// than silently mishandled, so a client relying on literals gets a clear
// BAD response instead of the server hanging waiting for bytes that were
// never announced as expected.
func (s *session) readCommand() (tag, cmd string, args []string, err error) {
line, err := s.rw.ReadString('\n')
if err != nil {
return "", "", nil, err
}
line = strings.TrimRight(line, "\r\n")
if len(line) > maxCommandLine {
return "", "", nil, fmt.Errorf("command line too long")
}
tokens := tokenize(line)
if len(tokens) < 2 {
return "", "", nil, fmt.Errorf("malformed command line: %q", line)
}
return tokens[0], strings.ToUpper(tokens[1]), tokens[2:], nil
}
// dispatch runs one command. Returns false if the session should close.
func (s *session) dispatch(tag, cmd string, args []string) bool {
switch cmd {
case "CAPABILITY":
s.cmdCapability(tag)
case "STARTTLS":
s.cmdStartTLS(tag)
case "NOOP":
s.tagged(tag, "OK NOOP completed")
case "LOGOUT":
s.untagged("BYE GoMail IMAP4rev1 server logging out")
s.tagged(tag, "OK LOGOUT completed")
return false
case "LOGIN":
s.cmdLogin(tag, args)
case "AUTHENTICATE":
s.tagged(tag, "NO AUTHENTICATE not supported, use LOGIN")
case "SELECT":
s.cmdSelectExamine(tag, args, true)
case "EXAMINE":
s.cmdSelectExamine(tag, args, false)
case "LIST":
s.cmdList(tag, args)
case "LSUB":
s.cmdList(tag, args) // no separate subscription tracking yet — LSUB mirrors LIST
case "CLOSE":
s.cmdClose(tag)
case "UNSELECT":
s.mailbox = ""
s.entries = nil
s.state = stateAuthenticated
s.tagged(tag, "OK UNSELECT completed")
case "FETCH":
s.cmdFetch(tag, args, false)
case "STORE":
s.cmdStore(tag, args, false)
case "SEARCH":
s.cmdSearch(tag, args, false)
case "EXPUNGE":
s.cmdExpunge(tag)
case "UID":
s.cmdUID(tag, args)
default:
s.tagged(tag, "BAD command not recognized")
}
return true
}
func (s *session) requireAuthenticated(tag string) bool {
if s.state == stateNotAuthenticated {
s.tagged(tag, "NO command requires authentication")
return false
}
return true
}
func (s *session) requireSelected(tag string) bool {
if s.state != stateSelected {
s.tagged(tag, "NO command requires a selected mailbox")
return false
}
return true
}
// ── I/O helpers ─────────────────────────────────────────────────────────────────
func (s *session) tagged(tag, response string) {
s.rw.WriteString(tag + " " + response + "\r\n")
s.rw.Flush()
}
func (s *session) untagged(response string) {
s.rw.WriteString("* " + response + "\r\n")
s.rw.Flush()
}
func (s *session) continuation(text string) {
s.rw.WriteString("+ " + text + "\r\n")
s.rw.Flush()
}
func (s *session) upgradeTLS(tlsConf *tls.Config) error {
tlsConn := tls.Server(s.conn, tlsConf)
if err := tlsConn.HandshakeContext(context.Background()); err != nil {
return err
}
s.conn = tlsConn
s.rw = bufio.NewReadWriter(bufio.NewReader(tlsConn), bufio.NewWriter(tlsConn))
return nil
}
// authenticateUser is the shared entry point LOGIN uses.
func (s *session) authenticateUser(username, password string) bool {
user, ok := auth.Authenticate(s.server.database, username, password, auth.ScopeIMAP)
if !ok {
return false
}
s.user = user
s.state = stateAuthenticated
return true
}
+33
View File
@@ -0,0 +1,33 @@
package imap
import "testing"
func FuzzTokenize(f *testing.F) {
f.Add(`a001 LOGIN user pass`)
f.Add(`a002 SELECT INBOX`)
f.Add(`a003 FETCH 1:* (FLAGS UID)`)
f.Add(`a004 SEARCH UNSEEN`)
f.Add(`a005 STORE 1 +FLAGS (\Seen)`)
f.Add(`a006 LOGIN "quoted user" "quoted pass"`)
f.Add("")
f.Add(`(((((`)
f.Add(`"unterminated`)
f.Add(`a007 LIST "" *`)
f.Add(`a008 UID FETCH 1 (BODY[HEADER])`)
f.Add(`nested (parens (inside (parens)))`)
f.Add("\x00\x01\x02 binary garbage")
f.Add(`"escaped \" quote"`)
f.Fuzz(func(t *testing.T, data string) {
// tokenize runs on every line a connected IMAP client sends, before
// any authentication has necessarily succeeded (e.g. the initial
// CAPABILITY/LOGIN exchange) — so it's exposed to fully untrusted
// network input and must never panic regardless of what's sent.
defer func() {
if r := recover(); r != nil {
t.Fatalf("tokenize panicked on input %q: %v", data, r)
}
}()
tokenize(data)
})
}
+293
View File
@@ -0,0 +1,293 @@
// Package imapclient is a minimal hand-rolled IMAP client used by
// provider_imap.go to talk to external IMAP servers (and, in tests, to
// GoMail's own IMAP server — proving client and server interoperate). No
// third-party IMAP library, matching the project's stdlib-first principle;
// this mirrors the parsing approach in internal/imap but for the client role.
package imapclient
import (
"bufio"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
)
type Client struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
tag int
}
// Dial connects and reads the server greeting. useTLS=true dials directly
// into TLS (implicit-TLS port); otherwise the connection starts plaintext
// and the caller may call StartTLS.
func Dial(addr string, useTLS bool, tlsConf *tls.Config, timeout time.Duration) (*Client, error) {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
conn.SetDeadline(time.Now().Add(timeout))
if useTLS {
conn = tls.Client(conn, tlsConf)
}
c := &Client{conn: conn, r: bufio.NewReader(conn), w: bufio.NewWriter(conn)}
if _, err := c.readLine(); err != nil { // discard greeting text, just confirm we got one
return nil, fmt.Errorf("reading greeting: %w", err)
}
return c, nil
}
func (c *Client) StartTLS(tlsConf *tls.Config) error {
if err := c.simpleCommand("STARTTLS"); err != nil {
return err
}
tlsConn := tls.Client(c.conn, tlsConf)
c.conn = tlsConn
c.r = bufio.NewReader(tlsConn)
c.w = bufio.NewWriter(tlsConn)
return nil
}
func (c *Client) Login(username, password string) error {
return c.simpleCommand(fmt.Sprintf(`LOGIN %s %s`, quote(username), quote(password)))
}
// LoginXOAUTH2 authenticates using an OAuth2 access token instead of a
// password — the mechanism Gmail and Microsoft 365 require for IMAP once
// "less secure app access" / basic auth is disabled, which is the default
// on both platforms today. saslPayload is base64-encoded here; callers
// build the raw payload via oauth2.XOAUTH2SASLString.
func (c *Client) LoginXOAUTH2(saslPayload string) error {
encoded := base64.StdEncoding.EncodeToString([]byte(saslPayload))
_, tagged, err := c.command("AUTHENTICATE XOAUTH2 " + encoded)
if err != nil {
return err
}
if !strings.Contains(tagged, "OK") {
return fmt.Errorf("XOAUTH2 authentication failed: %s", tagged)
}
return nil
}
func (c *Client) Logout() {
c.simpleCommand("LOGOUT")
c.conn.Close()
}
// FolderInfo is a parsed LIST response entry.
type FolderInfo struct {
Name string
}
func (c *Client) List() ([]FolderInfo, error) {
lines, tagged, err := c.command(`LIST "" "*"`)
if err != nil {
return nil, err
}
if !strings.Contains(tagged, "OK") {
return nil, fmt.Errorf("LIST failed: %s", tagged)
}
var folders []FolderInfo
for _, line := range lines {
if !strings.Contains(line, "LIST") {
continue
}
// "* LIST () "/" INBOX" — take the last whitespace-separated token,
// stripping quotes if present.
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
name := strings.Trim(fields[len(fields)-1], `"`)
folders = append(folders, FolderInfo{Name: name})
}
return folders, nil
}
// SelectedInfo reports what a SELECT told us about the mailbox.
type SelectedInfo struct {
Exists int
}
func (c *Client) Select(mailbox string) (*SelectedInfo, error) {
lines, tagged, err := c.command("SELECT " + quote(mailbox))
if err != nil {
return nil, err
}
if !strings.Contains(tagged, "OK") {
return nil, fmt.Errorf("SELECT failed: %s", tagged)
}
info := &SelectedInfo{}
existsRE := regexp.MustCompile(`^\* (\d+) EXISTS`)
for _, line := range lines {
if m := existsRE.FindStringSubmatch(line); m != nil {
info.Exists, _ = strconv.Atoi(m[1])
}
}
return info, nil
}
// FetchedMessage is one parsed FETCH response.
type FetchedMessage struct {
Seq int
UID int
Flags []string
Body []byte // present if BODY[] or BODY[HEADER] was requested
}
// Fetch runs FETCH seqSet items and parses the responses. items should be
// the raw IMAP item list, e.g. "(UID FLAGS BODY[])".
func (c *Client) Fetch(seqSet, items string) ([]FetchedMessage, error) {
lines, tagged, err := c.command(fmt.Sprintf("FETCH %s %s", seqSet, items))
if err != nil {
return nil, err
}
if !strings.Contains(tagged, "OK") {
return nil, fmt.Errorf("FETCH failed: %s", tagged)
}
return parseFetchLines(lines), nil
}
// UIDFetch runs "UID FETCH <uidSet> <items>" — the UID variant is a
// different command name on the wire (RFC 3501 §6.4.8), not a sequence-set
// prefix, so this is not just Fetch with a different first argument.
func (c *Client) UIDFetch(uidSet, items string) ([]FetchedMessage, error) {
lines, tagged, err := c.command(fmt.Sprintf("UID FETCH %s %s", uidSet, items))
if err != nil {
return nil, err
}
if !strings.Contains(tagged, "OK") {
return nil, fmt.Errorf("UID FETCH failed: %s", tagged)
}
return parseFetchLines(lines), nil
}
func (c *Client) Store(seqSet, action, flags string) error {
return c.simpleCommand(fmt.Sprintf("STORE %s %s (%s)", seqSet, action, flags))
}
// UIDStore is "UID STORE" — same command-name distinction as UIDFetch.
func (c *Client) UIDStore(uidSet, action, flags string) error {
return c.simpleCommand(fmt.Sprintf("UID STORE %s %s (%s)", uidSet, action, flags))
}
func (c *Client) Expunge() error {
return c.simpleCommand("EXPUNGE")
}
// ── Command plumbing ────────────────────────────────────────────────────────────
// command sends one tagged command and returns every untagged response line
// plus the final tagged status line.
func (c *Client) command(cmd string) (untagged []string, tagged string, err error) {
c.tag++
tag := fmt.Sprintf("C%03d", c.tag)
c.w.WriteString(tag + " " + cmd + "\r\n")
if err := c.w.Flush(); err != nil {
return nil, "", err
}
for {
line, err := c.readLine()
if err != nil {
return nil, "", err
}
if strings.HasPrefix(line, tag+" ") {
return untagged, line, nil
}
untagged = append(untagged, line)
}
}
func (c *Client) simpleCommand(cmd string) error {
_, tagged, err := c.command(cmd)
if err != nil {
return err
}
if !strings.Contains(tagged, "OK") {
return fmt.Errorf("%s failed: %s", strings.Fields(cmd)[0], tagged)
}
return nil
}
var literalRE = regexp.MustCompile(`\{(\d+)\+?\}$`)
// readLine reads one logical IMAP response line, transparently absorbing any
// literal ({N}\r\n<N bytes>) that appears in it — the literal's raw bytes
// (which may contain embedded CRLFs, exactly why literals exist) are spliced
// directly into the returned string, and reading continues until a line with
// no trailing literal marker is found, so a "BODY[] {123}\r\n<123
// bytes>)\r\n" response comes back as one complete string ending in ")".
func (c *Client) readLine() (string, error) {
var full strings.Builder
for {
chunk, err := c.r.ReadString('\n')
if err != nil {
return "", err
}
chunk = strings.TrimRight(chunk, "\r\n")
full.WriteString(chunk)
if m := literalRE.FindStringSubmatch(chunk); m != nil {
n, _ := strconv.Atoi(m[1])
buf := make([]byte, n)
if _, err := io.ReadFull(c.r, buf); err != nil {
return "", fmt.Errorf("reading literal (%d bytes): %w", n, err)
}
full.WriteString(string(buf))
continue // keep reading — more line content may follow the literal
}
return full.String(), nil
}
}
// ── Parsing ───────────────────────────────────────────────────────────────────
var fetchHeaderRE = regexp.MustCompile(`(?s)^\* (\d+) FETCH \((.*)\)$`)
var uidRE = regexp.MustCompile(`UID (\d+)`)
var flagsRE = regexp.MustCompile(`FLAGS \(([^)]*)\)`)
var bodyRE = regexp.MustCompile(`(?s)BODY(?:\.PEEK)?\[[A-Z]*\] \{\d+\}(.*)$`)
func parseFetchLines(lines []string) []FetchedMessage {
var out []FetchedMessage
for _, line := range lines {
m := fetchHeaderRE.FindStringSubmatch(line)
if m == nil {
continue
}
seq, _ := strconv.Atoi(m[1])
rest := m[2]
msg := FetchedMessage{Seq: seq}
if um := uidRE.FindStringSubmatch(rest); um != nil {
msg.UID, _ = strconv.Atoi(um[1])
}
if fm := flagsRE.FindStringSubmatch(rest); fm != nil {
if fm[1] != "" {
msg.Flags = strings.Fields(fm[1])
}
}
if bm := bodyRE.FindStringSubmatch(rest); bm != nil {
body := bm[1]
body = strings.TrimSuffix(body, ")")
msg.Body = []byte(body)
}
out = append(out, msg)
}
return out
}
func quote(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
+306
View File
@@ -0,0 +1,306 @@
// Package jmap implements a subset of JMAP Core (RFC 8620) and JMAP Mail
// (RFC 8621) — enough for a real JMAP client to discover the session,
// list mailboxes, and query/fetch messages. Scoped deliberately: Email/set
// (flag changes, delete), Email/import (send), and push (EventSource) are
// deferred, along with Sieve/ManageSieve entirely (RFC 5804, not started
// this phase — noted here, not silently skipped, since ManageSieve was
// originally paired with this phase in the plan).
//
// This exists alongside — not instead of — Phase 8's direct REST API,
// which the webmail SPA still uses. JMAP here is independently testable
// and available for third-party JMAP clients per the plan's config toggle
// (jmap.external_enabled); a later pass can migrate the SPA's internals to
// call this instead without changing its own REST contract.
package jmap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"gomail/internal/accounts"
"gomail/internal/auth"
"gomail/internal/db"
"gomail/internal/mailstore"
)
const (
coreCapability = "urn:ietf:params:jmap:core"
mailCapability = "urn:ietf:params:jmap:mail"
)
type Handler struct {
database *db.DB
store *mailstore.Store
hostname string
}
func NewHandler(database *db.DB, store *mailstore.Store, hostname string) *Handler {
return &Handler{database: database, store: store, hostname: hostname}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/.well-known/jmap", h.session)
mux.HandleFunc("/jmap/api", h.api)
}
// ── Session resource (RFC 8620 §2) ─────────────────────────────────────────────
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticate(r)
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="GoMail JMAP"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
resp := map[string]any{
"capabilities": map[string]any{
coreCapability: map[string]any{
"maxSizeUpload": 50 * 1024 * 1024,
"maxConcurrentUpload": 4,
"maxSizeRequest": 10 * 1024 * 1024,
"maxConcurrentRequests": 4,
"maxCallsInRequest": 16,
"maxObjectsInGet": 500,
"maxObjectsInSet": 500,
},
mailCapability: map[string]any{
"maxMailboxesPerEmail": 10,
"maxMailboxDepth": 1,
"maxSizeMailboxName": 255,
"maxSizeAttachmentsPerEmail": 50 * 1024 * 1024,
"emailQuerySortOptions": []string{"receivedAt"},
"mayCreateTopLevelMailbox": false,
},
},
"accounts": map[string]any{
user.ID: map[string]any{
"name": user.Email,
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": map[string]any{mailCapability: map[string]any{}},
},
},
"primaryAccounts": map[string]string{mailCapability: user.ID},
"username": user.Email,
"apiUrl": "/jmap/api",
"downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}",
"uploadUrl": "/jmap/upload/{accountId}",
"eventSourceUrl": "/jmap/events",
"state": "1",
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) authenticate(r *http.Request) (*db.User, bool) {
username, password, ok := r.BasicAuth()
if !ok {
return nil, false
}
return auth.Authenticate(h.database, username, password, auth.ScopeIMAP)
}
// ── API endpoint (RFC 8620 §3) ──────────────────────────────────────────────────
type request struct {
Using []string `json:"using"`
MethodCalls [][3]any `json:"methodCalls"`
}
type response struct {
MethodResponses [][3]any `json:"methodResponses"`
SessionState string `json:"sessionState"`
}
func (h *Handler) api(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticate(r)
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="GoMail JMAP"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JMAP request: "+err.Error(), http.StatusBadRequest)
return
}
resp := response{SessionState: "1"}
provider := accounts.NewGoMailProvider(h.database, h.store, user)
for _, call := range req.MethodCalls {
methodName, _ := call[0].(string)
args, _ := call[1].(map[string]any)
callID, _ := call[2].(string)
result := h.dispatch(r.Context(), provider, user, methodName, args)
resp.MethodResponses = append(resp.MethodResponses, [3]any{result.name, result.args, callID})
}
writeJSON(w, http.StatusOK, resp)
}
type methodResult struct {
name string
args map[string]any
}
func (h *Handler) dispatch(ctx context.Context, provider *accounts.GoMailProvider, user *db.User, method string, args map[string]any) methodResult {
switch method {
case "Core/echo":
return methodResult{name: "Core/echo", args: args}
case "Mailbox/get":
return h.mailboxGet(ctx, provider, user)
case "Email/query":
return h.emailQuery(ctx, provider, args)
case "Email/get":
return h.emailGet(ctx, provider, args)
default:
return methodResult{name: "error", args: map[string]any{"type": "unknownMethod", "description": fmt.Sprintf("method %q not implemented", method)}}
}
}
// ── Mailbox/get ───────────────────────────────────────────────────────────────
func (h *Handler) mailboxGet(ctx context.Context, provider *accounts.GoMailProvider, user *db.User) methodResult {
folders, err := provider.ListFolders(ctx)
if err != nil {
return methodResult{name: "error", args: map[string]any{"type": "serverFail", "description": err.Error()}}
}
var list []map[string]any
for _, f := range folders {
list = append(list, map[string]any{
"id": f.ID,
"name": f.DisplayName,
"role": jmapRole(f.Type),
"totalEmails": f.TotalCount,
"unreadEmails": f.UnreadCount,
"parentId": nil,
"sortOrder": 0,
"isSubscribed": true,
})
}
return methodResult{name: "Mailbox/get", args: map[string]any{
"accountId": user.ID, "state": "1", "list": list, "notFound": []string{},
}}
}
func jmapRole(folderType string) any {
switch folderType {
case "inbox":
return "inbox"
case "sent":
return "sent"
case "drafts":
return "drafts"
case "trash":
return "trash"
case "junk":
return "junk"
default:
return nil
}
}
// ── Email/query ───────────────────────────────────────────────────────────────
func (h *Handler) emailQuery(ctx context.Context, provider *accounts.GoMailProvider, args map[string]any) methodResult {
filter, _ := args["filter"].(map[string]any)
mailboxID := "INBOX"
if filter != nil {
if m, ok := filter["inMailbox"].(string); ok && m != "" {
mailboxID = m
}
}
headers, err := provider.ListMessages(ctx, mailboxID, accounts.ListOpts{})
if err != nil {
return methodResult{name: "error", args: map[string]any{"type": "serverFail", "description": err.Error()}}
}
ids := make([]string, len(headers))
for i, hdr := range headers {
ids[i] = mailboxID + ":" + hdr.ID // composite ID since JMAP IDs are global, ours are per-folder
}
return methodResult{name: "Email/query", args: map[string]any{
"ids": ids, "queryState": "1", "canCalculateChanges": false,
"position": 0, "total": len(ids),
}}
}
// ── Email/get ─────────────────────────────────────────────────────────────────
func (h *Handler) emailGet(ctx context.Context, provider *accounts.GoMailProvider, args map[string]any) methodResult {
rawIDs, _ := args["ids"].([]any)
var list []map[string]any
var notFound []string
for _, raw := range rawIDs {
compositeID, _ := raw.(string)
mailboxID, messageID, ok := splitCompositeID(compositeID)
if !ok {
notFound = append(notFound, compositeID)
continue
}
full, err := provider.GetMessage(ctx, mailboxID, messageID)
if err != nil {
notFound = append(notFound, compositeID)
continue
}
list = append(list, map[string]any{
"id": compositeID,
"mailboxIds": map[string]bool{mailboxID: true},
"from": []map[string]string{{"email": full.From}},
"to": []map[string]string{{"email": full.To}},
"subject": full.Subject,
"receivedAt": full.Date,
"size": full.SizeBytes,
"preview": truncatePreview(string(full.Raw)),
})
}
return methodResult{name: "Email/get", args: map[string]any{
"state": "1", "list": list, "notFound": notFound,
}}
}
func splitCompositeID(id string) (mailboxID, messageID string, ok bool) {
idx := strings.LastIndex(id, ":")
if idx == -1 {
return "", "", false
}
return id[:idx], id[idx+1:], true
}
func truncatePreview(raw string) string {
sep := "\r\n\r\n"
body := raw
if idx := strings.Index(raw, sep); idx >= 0 {
body = raw[idx+len(sep):]
}
if len(body) > 200 {
body = body[:200]
}
return body
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
+242
View File
@@ -0,0 +1,242 @@
// Package mailstore implements Maildir++-style on-disk message storage with
// every message encrypted at rest (AES-256-GCM, per-message key derived via
// HKDF from the master key — see internal/crypto).
package mailstore
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"gomail/internal/crypto"
"gomail/internal/db"
"github.com/google/uuid"
)
// Store writes and reads encrypted messages in a Maildir++ layout:
//
// {root}/{user-email}/{mailbox}/cur/{filename}.eml.enc
// {root}/{user-email}/{mailbox}/new/
// {root}/{user-email}/{mailbox}/tmp/
type Store struct {
root string
mk *crypto.MasterKey
db *db.DB
}
func New(root string, mk *crypto.MasterKey, database *db.DB) *Store {
return &Store{root: root, mk: mk, db: database}
}
// Deliver writes a raw message into a user's mailbox, encrypting it at rest,
// allocates the next IMAP UID, and records the mailbox_index row. Returns the
// assigned UID.
func (s *Store) Deliver(userID, userEmail, mailbox string, raw []byte) (uid int, err error) {
if err := s.ensureMailboxDirs(userEmail, mailbox); err != nil {
return 0, err
}
messageID := uuid.NewString()
encrypted, err := crypto.Encrypt(s.mk, messageID, "message", raw)
if err != nil {
return 0, fmt.Errorf("encrypt message: %w", err)
}
filename := maildirFilename(messageID)
tmpPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "tmp", filename)
finalPath := filepath.Join(s.mailboxDir(userEmail, mailbox), "cur", filename)
// Write to tmp/ then atomically rename into cur/ — standard Maildir delivery
// guarantee: a reader never observes a partially-written file.
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
return 0, fmt.Errorf("write tmp file: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
return 0, fmt.Errorf("atomic rename: %w", err)
}
allocatedUID, err := s.db.NextMailboxUID(userID, mailbox)
if err != nil {
return 0, fmt.Errorf("allocate uid: %w", err)
}
entry := &db.MailboxEntry{
ID: messageID,
UserID: userID,
Mailbox: mailbox,
UID: allocatedUID,
EMLPath: finalPath,
Flags: "",
SizeBytes: int64(len(raw)),
ReceivedAt: time.Now().UTC(),
InternalDate: time.Now().UTC(),
}
if err := s.db.InsertMailboxEntry(entry); err != nil {
// Best-effort cleanup of the file we just wrote — DB is the source of
// truth for what "exists"; an orphaned encrypted file with no index
// row is inert and harmless, but we try to avoid leaving one anyway.
os.Remove(finalPath)
return 0, fmt.Errorf("index mailbox entry: %w", err)
}
return allocatedUID, nil
}
// Read decrypts and returns the raw message bytes for a given encrypted file path.
// messageID must match the ID used at Deliver time (it's embedded in the filename).
func (s *Store) Read(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read encrypted file: %w", err)
}
messageID := messageIDFromFilename(filepath.Base(path))
plaintext, err := crypto.Decrypt(s.mk, messageID, "message", data)
if err != nil {
return nil, fmt.Errorf("decrypt message: %w", err)
}
return plaintext, nil
}
func (s *Store) ensureMailboxDirs(userEmail, mailbox string) error {
base := s.mailboxDir(userEmail, mailbox)
for _, sub := range []string{"cur", "new", "tmp"} {
if err := os.MkdirAll(filepath.Join(base, sub), 0700); err != nil {
return fmt.Errorf("creating maildir %s/%s: %w", mailbox, sub, err)
}
}
return nil
}
func (s *Store) mailboxDir(userEmail, mailbox string) string {
safeUser := sanitizePathComponent(userEmail)
safeMailbox := sanitizePathComponent(mailbox)
return filepath.Join(s.root, safeUser, safeMailbox)
}
// sanitizePathComponent prevents path traversal via crafted mailbox names or
// email addresses — strips any path separators or parent-directory references.
func sanitizePathComponent(s string) string {
s = strings.ReplaceAll(s, "/", "_")
s = strings.ReplaceAll(s, "\\", "_")
s = strings.ReplaceAll(s, "..", "_")
s = strings.TrimSpace(s)
if s == "" {
s = "_"
}
return s
}
// maildirFilename builds a Maildir-spec-ish unique filename embedding the
// message ID (needed later to re-derive the decryption key) plus a random
// suffix for readability/uniqueness under concurrent delivery.
func maildirFilename(messageID string) string {
suffix := make([]byte, 4)
rand.Read(suffix)
return fmt.Sprintf("%d.%s.%s.eml.enc", time.Now().UnixNano(), messageID, hex.EncodeToString(suffix))
}
func messageIDFromFilename(filename string) string {
parts := strings.Split(filename, ".")
if len(parts) >= 2 {
return parts[1]
}
return ""
}
// WriteQueueFile encrypts and stores a message destined for outbound
// delivery, separately from any user's Maildir (it's not "in" a mailbox
// until/unless it becomes a Sent-folder copy after successful delivery —
// that wiring lands with the webmail Sent view in a later phase). Returns
// the messageID (used as both the encryption record ID and to find the file
// again) and the file path to record in outbound_queue.eml_path.
func (s *Store) WriteQueueFile(raw []byte) (messageID, path string, err error) {
queueDir := filepath.Join(s.root, ".queue")
if err := os.MkdirAll(queueDir, 0700); err != nil {
return "", "", fmt.Errorf("creating queue dir: %w", err)
}
messageID = uuid.NewString()
encrypted, err := crypto.Encrypt(s.mk, messageID, "message", raw)
if err != nil {
return "", "", fmt.Errorf("encrypt queued message: %w", err)
}
filename := maildirFilename(messageID)
tmpPath := filepath.Join(queueDir, "tmp-"+filename)
finalPath := filepath.Join(queueDir, filename)
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
return "", "", fmt.Errorf("write queue tmp file: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
return "", "", fmt.Errorf("atomic rename: %w", err)
}
return messageID, finalPath, nil
}
// DeleteQueueFile removes a queue file after successful delivery or a
// generated bounce — called by the queue worker.
func (s *Store) DeleteQueueFile(path string) error {
return os.Remove(path)
}
// WriteQuarantineFile encrypts and stores a held message using the given
// messageID (the same ID as its `messages` audit row) rather than generating
// a new one — so release-time decryption can key off the ID already on hand
// from the quarantine/messages tables without needing to parse it back out
// of a filename.
func (s *Store) WriteQuarantineFile(messageID string, raw []byte) (path string, err error) {
qDir := filepath.Join(s.root, ".quarantine")
if err := os.MkdirAll(qDir, 0700); err != nil {
return "", fmt.Errorf("creating quarantine dir: %w", err)
}
encrypted, err := crypto.Encrypt(s.mk, messageID, "message", raw)
if err != nil {
return "", fmt.Errorf("encrypt quarantined message: %w", err)
}
filename := messageID + ".eml.enc"
tmpPath := filepath.Join(qDir, "tmp-"+filename)
finalPath := filepath.Join(qDir, filename)
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
return "", fmt.Errorf("write quarantine tmp file: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("atomic rename: %w", err)
}
return finalPath, nil
}
// ReadQuarantineFile decrypts a quarantined message given its messageID
// (needed because quarantine files are keyed by ID directly, not embedded
// in the filename the way Deliver's maildirFilename embeds it).
func (s *Store) ReadQuarantineFile(messageID, path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read quarantine file: %w", err)
}
return crypto.Decrypt(s.mk, messageID, "message", data)
}
// a large message is needed (future IMAP partial FETCH support) — for now it
// simply decrypts and returns the full body since GCM doesn't support
// streaming partial decryption without the full ciphertext.
func (s *Store) ReadAt(path string, w io.Writer) error {
data, err := s.Read(path)
if err != nil {
return err
}
_, err = w.Write(data)
return err
}
+90
View File
@@ -0,0 +1,90 @@
// Package managesieve implements a RFC 5804 ManageSieve server — the
// protocol mail clients (Thunderbird's Sieve plugin, etc.) use to upload and
// manage server-side filtering scripts. Every uploaded script is validated
// with internal/sieve's parser before being stored, so a syntactically
// invalid script is rejected at PUTSCRIPT time rather than silently failing
// at delivery time.
package managesieve
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net"
"sync"
"time"
"gomail/internal/db"
)
const idleTimeout = 10 * time.Minute
type Server struct {
database *db.DB
tlsConf *tls.Config
hostname string
listener net.Listener
wg sync.WaitGroup
sessionWG sync.WaitGroup
}
func NewServer(database *db.DB, tlsConf *tls.Config, hostname string) *Server {
return &Server{database: database, tlsConf: tlsConf, hostname: hostname}
}
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
s.listener = ln
slog.Info("ManageSieve listener started", "addr", addr)
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.acceptLoop(ctx, ln)
}()
<-ctx.Done()
return ctx.Err()
}
func (s *Server) acceptLoop(ctx context.Context, ln net.Listener) {
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
slog.Error("ManageSieve accept error", "err", err)
return
}
}
s.sessionWG.Add(1)
go func() {
defer s.sessionWG.Done()
newSession(conn, s).run(ctx)
}()
}
}
func (s *Server) Shutdown(gracePeriod time.Duration) {
if s.listener != nil {
s.listener.Close()
}
s.wg.Wait()
done := make(chan struct{})
go func() {
s.sessionWG.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(gracePeriod):
slog.Warn("ManageSieve shutdown grace period expired")
}
}
+359
View File
@@ -0,0 +1,359 @@
package managesieve
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"log/slog"
"net"
"strconv"
"strings"
"time"
"gomail/internal/auth"
"gomail/internal/db"
"gomail/internal/sieve"
"github.com/google/uuid"
)
type session struct {
conn net.Conn
rw *bufio.ReadWriter
server *Server
tlsActive bool
user *db.User
}
func newSession(conn net.Conn, server *Server) *session {
_, isTLS := conn.(*tls.Conn)
return &session{
conn: conn,
rw: bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)),
server: server,
tlsActive: isTLS,
}
}
func (s *session) run(ctx context.Context) {
s.sendCapabilities()
for {
select {
case <-ctx.Done():
s.writeLine(`BYE "server shutting down"`)
return
default:
}
s.conn.SetReadDeadline(time.Now().Add(idleTimeout))
line, err := s.readLine()
if err != nil {
if err != io.EOF {
slog.Debug("ManageSieve read error", "err", err)
}
return
}
if !s.dispatch(line) {
return
}
}
}
func (s *session) sendCapabilities() {
s.writeLine(`"IMPLEMENTATION" "GoMail ManageSieve"`)
s.writeLine(`"SIEVE" "fileinto"`)
s.writeLine(`"VERSION" "1.0"`)
if !s.tlsActive {
s.writeLine(`"STARTTLS"`)
}
s.writeLine("OK")
}
func (s *session) dispatch(line string) bool {
verb, rest := splitVerb(line)
switch strings.ToUpper(verb) {
case "CAPABILITY":
s.sendCapabilities()
case "STARTTLS":
s.cmdStartTLS()
case "AUTHENTICATE":
s.cmdAuthenticate(rest)
case "LOGOUT":
s.writeLine("OK")
return false
case "PUTSCRIPT":
s.cmdPutScript(rest)
case "GETSCRIPT":
s.cmdGetScript(rest)
case "LISTSCRIPTS":
s.cmdListScripts()
case "SETACTIVE":
s.cmdSetActive(rest)
case "DELETESCRIPT":
s.cmdDeleteScript(rest)
case "NOOP":
s.writeLine("OK")
default:
s.writeLine(`NO "command not recognized"`)
}
return true
}
func (s *session) cmdStartTLS() {
if s.tlsActive {
s.writeLine(`NO "TLS already active"`)
return
}
s.writeLine("OK")
tlsConn := tls.Server(s.conn, s.server.tlsConf)
if err := tlsConn.HandshakeContext(context.Background()); err != nil {
return
}
s.conn = tlsConn
s.rw = bufio.NewReadWriter(bufio.NewReader(tlsConn), bufio.NewWriter(tlsConn))
s.tlsActive = true
}
// cmdAuthenticate handles AUTHENTICATE "PLAIN" <base64> — the SASL PLAIN
// mechanism, same as SMTP/IMAP's AUTH PLAIN, adapted to ManageSieve's quoted
// string argument syntax rather than a bare base64 token.
func (s *session) cmdAuthenticate(rest string) {
if !s.tlsActive {
s.writeLine(`NO "authentication requires TLS — use STARTTLS first"`)
return
}
parts := splitQuotedArgs(rest)
if len(parts) < 1 || strings.ToUpper(strings.Trim(parts[0], `"`)) != "PLAIN" {
s.writeLine(`NO "only AUTHENTICATE PLAIN is supported"`)
return
}
var b64 string
if len(parts) >= 2 {
b64 = strings.Trim(parts[1], `"`)
} else {
s.writeLine("{0}")
line, err := s.readLine()
if err != nil {
return
}
b64 = line
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
s.writeLine(`NO "malformed SASL response"`)
return
}
fields := strings.SplitN(string(decoded), "\x00", 3)
if len(fields) != 3 {
s.writeLine(`NO "malformed SASL PLAIN payload"`)
return
}
username, password := fields[1], fields[2]
user, ok := auth.Authenticate(s.server.database, username, password, auth.ScopeIMAP)
if !ok {
s.writeLine(`NO "authentication failed"`)
return
}
s.user = user
s.writeLine("OK")
}
func (s *session) requireAuth() bool {
if s.user == nil {
s.writeLine(`NO "authentication required"`)
return false
}
return true
}
// cmdPutScript handles: PUTSCRIPT "name" {N+}\r\n<N bytes of script>\r\n
func (s *session) cmdPutScript(rest string) {
if !s.requireAuth() {
return
}
parts := splitQuotedArgs(rest)
if len(parts) < 1 {
s.writeLine(`NO "PUTSCRIPT requires a script name"`)
return
}
name := strings.Trim(parts[0], `"`)
scriptText, err := s.readLiteralFromRemainder(rest)
if err != nil {
s.writeLine(`NO "expected script literal: ` + err.Error() + `"`)
return
}
if _, err := sieve.Parse(scriptText); err != nil {
s.writeLine(`NO "script failed to parse: ` + escapeQuoted(err.Error()) + `"`)
return
}
if err := s.server.database.UpsertSieveScript(&db.SieveScript{
ID: uuid.NewString(), UserID: s.user.ID, Name: name, ScriptText: scriptText,
}); err != nil {
s.writeLine(`NO "storage error"`)
return
}
s.writeLine("OK")
}
func (s *session) cmdGetScript(rest string) {
if !s.requireAuth() {
return
}
name := strings.Trim(strings.TrimSpace(rest), `"`)
script, err := s.server.database.GetSieveScript(s.user.ID, name)
if err != nil {
s.writeLine(`NO "script not found"`)
return
}
s.writeLine(fmt.Sprintf("{%d}", len(script.ScriptText)))
s.rw.WriteString(script.ScriptText)
s.rw.WriteString("\r\n")
s.rw.Flush()
s.writeLine("OK")
}
func (s *session) cmdListScripts() {
if !s.requireAuth() {
return
}
scripts, err := s.server.database.ListSieveScripts(s.user.ID)
if err != nil {
s.writeLine(`NO "storage error"`)
return
}
for _, sc := range scripts {
if sc.Active {
s.writeLine(fmt.Sprintf(`"%s" ACTIVE`, sc.Name))
} else {
s.writeLine(fmt.Sprintf(`"%s"`, sc.Name))
}
}
s.writeLine("OK")
}
func (s *session) cmdSetActive(rest string) {
if !s.requireAuth() {
return
}
name := strings.Trim(strings.TrimSpace(rest), `"`)
if name == "" {
// Empty name deactivates all scripts, per RFC 5804 §2.9.
s.server.database.Exec(`UPDATE sieve_scripts SET active = 0 WHERE user_id = ?`, s.user.ID)
s.writeLine("OK")
return
}
if err := s.server.database.SetActiveSieveScript(s.user.ID, name); err != nil {
s.writeLine(`NO "script not found"`)
return
}
s.writeLine("OK")
}
func (s *session) cmdDeleteScript(rest string) {
if !s.requireAuth() {
return
}
name := strings.Trim(strings.TrimSpace(rest), `"`)
if err := s.server.database.DeleteSieveScript(s.user.ID, name); err != nil {
s.writeLine(`NO "delete failed"`)
return
}
s.writeLine("OK")
}
// ── I/O helpers ─────────────────────────────────────────────────────────────────
func (s *session) writeLine(line string) {
s.rw.WriteString(line + "\r\n")
s.rw.Flush()
}
func (s *session) readLine() (string, error) {
line, err := s.rw.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// readLiteralFromRemainder expects the command line's remainder to end in a
// {N} or {N+} literal announcement (RFC 5804 reuses IMAP-style literal
// syntax) and reads exactly N raw bytes following it.
func (s *session) readLiteralFromRemainder(rest string) (string, error) {
idx := strings.LastIndex(rest, "{")
if idx == -1 || !strings.HasSuffix(strings.TrimSpace(rest), "}") {
return "", fmt.Errorf("no literal size announced")
}
sizeStr := strings.TrimSuffix(strings.TrimSpace(rest[idx+1:]), "}")
sizeStr = strings.TrimSuffix(sizeStr, "+")
n, err := strconv.Atoi(sizeStr)
if err != nil {
return "", fmt.Errorf("invalid literal size: %w", err)
}
buf := make([]byte, n)
if _, err := io.ReadFull(s.rw, buf); err != nil {
return "", fmt.Errorf("reading literal: %w", err)
}
s.rw.ReadString('\n') // consume trailing CRLF after the literal bytes
return string(buf), nil
}
func splitVerb(line string) (verb, rest string) {
line = strings.TrimSpace(line)
i := strings.IndexAny(line, " \t")
if i < 0 {
return line, ""
}
return line[:i], strings.TrimSpace(line[i+1:])
}
// splitQuotedArgs splits `"arg1" "arg2"` into ["arg1","arg2"] (quotes kept,
// stripped by callers as needed) — tolerant of a trailing {N+} literal
// marker, which callers handle separately via readLiteralFromRemainder.
func splitQuotedArgs(s string) []string {
var args []string
i := 0
for i < len(s) {
for i < len(s) && s[i] == ' ' {
i++
}
if i >= len(s) {
break
}
if s[i] == '"' {
j := i + 1
for j < len(s) && s[j] != '"' {
j++
}
if j < len(s) {
args = append(args, s[i:j+1])
i = j + 1
} else {
break
}
} else {
j := i
for j < len(s) && s[j] != ' ' {
j++
}
args = append(args, s[i:j])
i = j
}
}
return args
}
func escapeQuoted(s string) string {
return strings.ReplaceAll(s, `"`, `'`)
}
+163
View File
@@ -0,0 +1,163 @@
// Package oauth2 implements the OAuth2 authorization code grant (RFC 6749
// §4.1) and token refresh (§6) — hand-rolled on net/http + encoding/json,
// no third-party OAuth2 library, matching the project's dependency-minimal
// principle. This is genuinely small (~150 lines) once you're not carrying
// a general-purpose library's support for every grant type GoMail doesn't
// use.
package oauth2
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Config holds one provider's OAuth2 app registration — operator-supplied
// (Client ID/Secret from their own Google Cloud / Azure AD app
// registration, per the plan's "self-hosted operators register their own
// app" decision) plus the provider's well-known endpoints.
type Config struct {
ClientID string
ClientSecret string
RedirectURI string
AuthURL string
TokenURL string
Scopes []string
}
// WellKnownEndpoints returns the real, fixed endpoint URLs for supported
// providers — these are NOT operator-configurable (only ClientID/Secret
// are), since pointing "google" at an arbitrary URL would defeat the point
// of naming a known provider. Tests construct a Config directly with
// endpoints pointed at a local fake server instead of using this function.
func WellKnownEndpoints(provider string) (authURL, tokenURL string, err error) {
switch provider {
case "google":
return "https://accounts.google.com/o/oauth2/v2/auth", "https://oauth2.googleapis.com/token", nil
case "microsoft":
return "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"https://login.microsoftonline.com/common/oauth2/v2.0/token", nil
default:
return "", "", fmt.Errorf("unknown provider %q", provider)
}
}
// Token is what the provider returns from a code exchange or refresh.
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` // may be empty on a refresh response — providers don't always rotate it
TokenType string `json:"token_type"`
ExpiresAt time.Time `json:"expires_at"`
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
// BuildAuthURL constructs the URL to redirect the user's browser to. state
// is a caller-generated random value (CSRF protection — the caller must
// verify the same value comes back on the callback) — see webmail's
// oauthStart handler for how it's generated and stored.
func (c *Config) BuildAuthURL(state string) string {
v := url.Values{}
v.Set("client_id", c.ClientID)
v.Set("redirect_uri", c.RedirectURI)
v.Set("response_type", "code")
v.Set("scope", strings.Join(c.Scopes, " "))
v.Set("state", state)
v.Set("access_type", "offline") // request a refresh_token (Google-specific but harmless elsewhere)
v.Set("prompt", "consent")
return c.AuthURL + "?" + v.Encode()
}
// ExchangeCode trades an authorization code (from the callback's ?code=
// query param) for an access + refresh token.
func (c *Config) ExchangeCode(ctx context.Context, code string) (*Token, error) {
form := url.Values{}
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("redirect_uri", c.RedirectURI)
form.Set("code", code)
form.Set("grant_type", "authorization_code")
return c.doTokenRequest(ctx, form)
}
// RefreshToken exchanges a stored refresh token for a new access token.
func (c *Config) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) {
form := url.Values{}
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("refresh_token", refreshToken)
form.Set("grant_type", "refresh_token")
tok, err := c.doTokenRequest(ctx, form)
if err != nil {
return nil, err
}
if tok.RefreshToken == "" {
tok.RefreshToken = refreshToken // providers often omit it on refresh — keep the old one
}
return tok, nil
}
func (c *Config) doTokenRequest(ctx context.Context, form url.Values) (*Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("building token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading token response: %w", err)
}
var tr tokenResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("parsing token response: %w (body: %s)", err, truncate(body, 200))
}
if tr.Error != "" {
return nil, fmt.Errorf("oauth2 error: %s (%s)", tr.Error, tr.ErrorDesc)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, truncate(body, 200))
}
return &Token{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
TokenType: tr.TokenType,
ExpiresAt: time.Now().UTC().Add(time.Duration(tr.ExpiresIn) * time.Second),
}, nil
}
func truncate(b []byte, n int) string {
if len(b) > n {
return string(b[:n]) + "..."
}
return string(b)
}
// XOAUTH2SASLString builds the SASL XOAUTH2 initial-response string (used
// by IMAP/SMTP clients authenticating with an OAuth2 access token instead
// of a password) per Google's documented format, which Microsoft also
// accepts for IMAP: "user=<email>\x01auth=Bearer <token>\x01\x01".
func XOAUTH2SASLString(email, accessToken string) string {
return "user=" + email + "\x01auth=Bearer " + accessToken + "\x01\x01"
}
+163
View File
@@ -0,0 +1,163 @@
// Package pipeline implements the inbound security pipeline: SPF, DKIM
// verification, DMARC, header/URL heuristics — each stage contributes a
// score, and the orchestrator maps the total score to a verdict (clean,
// flagged, quarantine, blocked) per the configured thresholds.
//
// Every stage is blocking and runs before the SMTP DATA response — no
// third-party spam-filtering library, entirely stdlib DNS/crypto/net/mail.
package pipeline
import (
"context"
"net"
"net/mail"
"strings"
"time"
"gomail/internal/config"
"gomail/internal/db"
)
// MailContext carries everything a stage needs and accumulates results.
type MailContext struct {
SenderIP net.IP
SenderHost string
MailFrom string
RcptTo string
RawMessage []byte
Checks []StageResult
TotalScore float64
Verdict db.MessageVerdict
parsedMessage *mail.Message
parseErr error
parseAttempted bool
}
// ParsedMessage lazily parses RawMessage via net/mail — stages call this
// instead of parsing independently, so the (relatively expensive) header
// parse happens at most once per message regardless of how many stages need it.
func (mc *MailContext) ParsedMessage() (*mail.Message, error) {
if !mc.parseAttempted {
mc.parsedMessage, mc.parseErr = mail.ReadMessage(strings.NewReader(string(mc.RawMessage)))
mc.parseAttempted = true
}
return mc.parsedMessage, mc.parseErr
}
// RcptDomain returns the domain portion of RcptTo.
func (mc *MailContext) RcptDomain() string {
return domainOf(mc.RcptTo)
}
// MailFromDomain returns the domain portion of the envelope sender.
func (mc *MailContext) MailFromDomain() string {
return domainOf(mc.MailFrom)
}
func domainOf(addr string) string {
parts := strings.SplitN(strings.ToLower(addr), "@", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
// StageResult is one check's outcome.
type StageResult struct {
Stage string
Result db.CheckResult
Score float64
Detail string
DurationMs int64
}
// Stage is one pipeline check. Run must not block indefinitely — pass a
// context with a deadline and respect it for any network I/O (DNS lookups).
type Stage interface {
Name() string
Run(ctx context.Context, mc *MailContext) *StageResult
}
// Orchestrator runs the configured stages in order and computes the verdict.
type Orchestrator struct {
stages []Stage
cfg *config.Config
}
func NewOrchestrator(cfg *config.Config, stages []Stage) *Orchestrator {
return &Orchestrator{stages: stages, cfg: cfg}
}
// DefaultStages returns the deterministic, always-on stage set (SPF, DKIM,
// DMARC, header anomaly, URL heuristics) — no external service
// dependencies, always safe to run regardless of what's configured.
func DefaultStages() []Stage {
return []Stage{
&SPFStage{},
&DKIMStage{},
&DMARCStage{},
&HeaderStage{},
&URLStage{},
}
}
// StagesFromConfig returns DefaultStages() plus any of the optional
// external-service stages (ClamAV, Rspamd, LLM) that config has an address
// configured for — each is entirely absent from the pipeline, not merely
// disabled, when its config field is empty, so an unreachable/misconfigured
// service that was never intended to be used can't accidentally affect
// delivery.
func StagesFromConfig(cfg *config.Config) []Stage {
stages := DefaultStages()
if cfg.Pipeline.ClamAVSocket != "" {
stages = append(stages, &ClamAVStage{Addr: cfg.Pipeline.ClamAVSocket, Timeout: 30 * time.Second})
}
if cfg.Pipeline.RspamdURL != "" {
stages = append(stages, &RspamdStage{BaseURL: cfg.Pipeline.RspamdURL, Timeout: 15 * time.Second})
}
if cfg.Pipeline.LLMURL != "" {
timeout := time.Duration(cfg.Pipeline.LLMTimeoutSecs) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
stages = append(stages, &LLMStage{BaseURL: cfg.Pipeline.LLMURL, Model: cfg.Pipeline.LLMModel, Timeout: timeout})
}
return stages
}
// Run executes every stage in order, accumulating score, and computes the
// final verdict against the configured thresholds. Individual stage panics
// are not recovered here deliberately — a panicking stage is a bug that
// should surface loudly in testing, not be silently swallowed in production
// and misclassify mail.
func (o *Orchestrator) Run(ctx context.Context, mc *MailContext) {
for _, stage := range o.stages {
start := time.Now()
result := stage.Run(ctx, mc)
if result == nil {
continue
}
result.DurationMs = time.Since(start).Milliseconds()
mc.Checks = append(mc.Checks, *result)
mc.TotalScore += result.Score
}
mc.Verdict = verdictFor(mc.TotalScore, o.cfg.Pipeline)
}
func verdictFor(score float64, p config.PipelineConfig) db.MessageVerdict {
switch {
case score >= p.ScoreBlock:
return db.VerdictBlocked
case score >= p.ScoreQuarantine:
return db.VerdictQuarantine
case score >= p.ScoreFlag:
return db.VerdictFlagged
default:
return db.VerdictClean
}
}
+127
View File
@@ -0,0 +1,127 @@
package pipeline
import (
"context"
"encoding/binary"
"fmt"
"net"
"strings"
"time"
"gomail/internal/db"
)
// ClamAVStage scans the raw message via clamd's INSTREAM protocol — a
// small, well-documented binary protocol (no third-party clamd client
// library): send "zINSTREAM\0", then the message in 4-byte-big-endian-
// length-prefixed chunks terminated by a zero-length chunk, then read the
// single-line response ("stream: OK", "stream: <name> FOUND", or
// "stream: <error>"). Off by default — only active when
// config.Pipeline.ClamAVSocket is set.
type ClamAVStage struct {
Addr string // "unix:/var/run/clamav/clamd.ctl" or "tcp:127.0.0.1:3310"
Timeout time.Duration
}
func (s *ClamAVStage) Name() string { return "clamav" }
func (s *ClamAVStage) Run(ctx context.Context, mc *MailContext) *StageResult {
start := time.Now()
result := &StageResult{Stage: s.Name()}
verdict, detail, err := s.scan(ctx, mc.RawMessage)
result.DurationMs = time.Since(start).Milliseconds()
if err != nil {
result.Result = db.CheckError
result.Detail = "clamd scan failed: " + err.Error()
return result
}
switch verdict {
case "FOUND":
result.Result = db.CheckFail
result.Score = 100 // malware is always a hard block, not a scored contribution
result.Detail = "malware detected: " + detail
case "OK":
result.Result = db.CheckPass
result.Detail = "clean"
default:
result.Result = db.CheckError
result.Detail = "unexpected clamd response: " + detail
}
return result
}
func (s *ClamAVStage) scan(ctx context.Context, raw []byte) (verdict, detail string, err error) {
network, address, err := parseClamAddr(s.Addr)
if err != nil {
return "", "", err
}
dialer := net.Dialer{Timeout: s.Timeout}
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return "", "", fmt.Errorf("connecting to clamd: %w", err)
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
conn.SetDeadline(deadline)
} else if s.Timeout > 0 {
conn.SetDeadline(time.Now().Add(s.Timeout))
}
if _, err := conn.Write([]byte("zINSTREAM\x00")); err != nil {
return "", "", fmt.Errorf("sending INSTREAM command: %w", err)
}
const chunkSize = 8192
for i := 0; i < len(raw); i += chunkSize {
end := i + chunkSize
if end > len(raw) {
end = len(raw)
}
chunk := raw[i:end]
lenBuf := make([]byte, 4)
binary.BigEndian.PutUint32(lenBuf, uint32(len(chunk)))
if _, err := conn.Write(lenBuf); err != nil {
return "", "", fmt.Errorf("writing chunk length: %w", err)
}
if _, err := conn.Write(chunk); err != nil {
return "", "", fmt.Errorf("writing chunk data: %w", err)
}
}
if _, err := conn.Write([]byte{0, 0, 0, 0}); err != nil {
return "", "", fmt.Errorf("writing terminator: %w", err)
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return "", "", fmt.Errorf("reading clamd response: %w", err)
}
response := strings.TrimRight(string(buf[:n]), "\x00\r\n")
switch {
case strings.HasSuffix(response, "OK"):
return "OK", response, nil
case strings.Contains(response, "FOUND"):
return "FOUND", response, nil
default:
return "ERROR", response, nil
}
}
// parseClamAddr accepts "unix:/path/to/socket" or "tcp:host:port" —
// explicit scheme prefix rather than sniffing, so a misconfigured address
// fails loudly at startup instead of guessing wrong.
func parseClamAddr(addr string) (network, address string, err error) {
switch {
case strings.HasPrefix(addr, "unix:"):
return "unix", strings.TrimPrefix(addr, "unix:"), nil
case strings.HasPrefix(addr, "tcp:"):
return "tcp", strings.TrimPrefix(addr, "tcp:"), nil
default:
return "", "", fmt.Errorf("clamav_socket must start with 'unix:' or 'tcp:', got %q", addr)
}
}
+56
View File
@@ -0,0 +1,56 @@
package pipeline
import (
"context"
"fmt"
"net"
"strings"
"gomail/internal/db"
"gomail/internal/dkim"
)
type DKIMStage struct{}
func (s *DKIMStage) Name() string { return "dkim" }
func (s *DKIMStage) Run(ctx context.Context, mc *MailContext) *StageResult {
domain, selector, found := dkim.ExtractSignatureInfo(mc.RawMessage)
if !found {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: "no DKIM-Signature header present"}
}
dnsHost := selector + "._domainkey." + domain
resolver := net.DefaultResolver
txts, err := resolver.LookupTXT(ctx, dnsHost)
if err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 8,
Detail: fmt.Sprintf("DKIM public key DNS lookup failed for %s: %v", dnsHost, err)}
}
var record string
for _, txt := range txts {
if strings.Contains(txt, "p=") {
record = txt
break
}
}
if record == "" {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 8,
Detail: fmt.Sprintf("no DKIM key record found at %s", dnsHost)}
}
pubDER, err := dkim.ParseDNSPublicKey(record)
if err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 5,
Detail: fmt.Sprintf("malformed DKIM public key at %s: %v", dnsHost, err)}
}
if err := dkim.Verify(pubDER, mc.RawMessage); err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 20,
Detail: fmt.Sprintf("DKIM signature verification failed (d=%s s=%s): %v", domain, selector, err)}
}
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Score: 0,
Detail: fmt.Sprintf("DKIM signature valid (d=%s s=%s)", domain, selector)}
}
+117
View File
@@ -0,0 +1,117 @@
package pipeline
import (
"context"
"fmt"
"net"
"strings"
"gomail/internal/db"
)
type DMARCStage struct{}
func (s *DMARCStage) Name() string { return "dmarc" }
func (s *DMARCStage) Run(ctx context.Context, mc *MailContext) *StageResult {
msg, err := mc.ParsedMessage()
if err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 3,
Detail: fmt.Sprintf("could not parse message headers: %v", err)}
}
fromHeader := msg.Header.Get("From")
fromDomain := extractDomainFromHeader(fromHeader)
if fromDomain == "" {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: "could not parse From header domain"}
}
// Alignment: does the RFC 5322 From domain match (or share an
// organisational domain with) the envelope MAIL FROM domain that SPF
// already checked? Misalignment is exactly what DMARC exists to catch —
// SPF/DKIM passing for a *different* domain than what the user sees in
// their inbox is a classic spoofing pattern.
envelopeDomain := mc.MailFromDomain()
aligned := envelopeDomain != "" && (fromDomain == envelopeDomain || orgDomain(fromDomain) == orgDomain(envelopeDomain))
resolver := net.DefaultResolver
txts, err := resolver.LookupTXT(ctx, "_dmarc."+fromDomain)
if err != nil || len(txts) == 0 {
// Fall back to organisational domain per RFC 7489 §6.6.3. A failure
// here just leaves txts empty, handled by the "no record found"
// check below — no separate error path needed.
org := orgDomain(fromDomain)
if org != fromDomain {
txts, _ = resolver.LookupTXT(ctx, "_dmarc."+org)
}
}
var record string
for _, txt := range txts {
if strings.HasPrefix(strings.ToLower(txt), "v=dmarc1") {
record = txt
break
}
}
if record == "" {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5,
Detail: fmt.Sprintf("no DMARC record published for %s", fromDomain)}
}
policy := dmarcTag(record, "p")
detail := fmt.Sprintf("DMARC policy=%s for %s, envelope/header alignment=%v", policy, fromDomain, aligned)
if aligned {
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Score: 0, Detail: detail}
}
switch policy {
case "reject":
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 25, Detail: detail}
case "quarantine":
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 15, Detail: detail}
default: // "none" or unrecognised
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: detail}
}
}
func extractDomainFromHeader(headerValue string) string {
// RFC 5322 From can be "Name <addr@domain>" or bare "addr@domain".
addr := headerValue
if i := strings.Index(headerValue, "<"); i >= 0 {
if j := strings.Index(headerValue[i:], ">"); j >= 0 {
addr = headerValue[i+1 : i+j]
}
}
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(addr)), "@", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
// orgDomain approximates the "organisational domain" (RFC 7489 §3.2) by
// taking the last two labels — good enough for common TLDs (.com, .net,
// .org). It does not consult the Public Suffix List, so it will
// mis-identify the org domain for domains under multi-label public suffixes
// like .co.uk; that refinement can be added later without changing the
// stage's shape (it would only affect the fallback DNS lookup and the
// alignment comparison, both isolated to this one helper).
func orgDomain(domain string) string {
labels := strings.Split(domain, ".")
if len(labels) <= 2 {
return domain
}
return strings.Join(labels[len(labels)-2:], ".")
}
func dmarcTag(record, tag string) string {
for _, part := range strings.Split(record, ";") {
part = strings.TrimSpace(part)
name, value, found := strings.Cut(part, "=")
if found && strings.TrimSpace(name) == tag {
return strings.TrimSpace(value)
}
}
return ""
}
+80
View File
@@ -0,0 +1,80 @@
package pipeline
import (
"fmt"
"context"
"strings"
"gomail/internal/db"
)
type HeaderStage struct{}
func (s *HeaderStage) Name() string { return "headers" }
func (s *HeaderStage) Run(_ context.Context, mc *MailContext) *StageResult {
msg, err := mc.ParsedMessage()
if err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 5,
Detail: fmt.Sprintf("could not parse headers: %v", err)}
}
var issues []string
score := 0.0
if msg.Header.Get("From") == "" {
issues = append(issues, "missing From header")
score += 15
}
if msg.Header.Get("Subject") == "" {
issues = append(issues, "missing Subject header")
score += 3
}
if msg.Header.Get("Date") == "" {
issues = append(issues, "missing Date header")
score += 5
}
fromDomain := extractDomainFromHeader(msg.Header.Get("From"))
envDomain := mc.MailFromDomain()
if fromDomain != "" && envDomain != "" && fromDomain != envDomain {
issues = append(issues, fmt.Sprintf("From header domain (%s) differs from envelope sender (%s)", fromDomain, envDomain))
score += 10
}
if replyTo := msg.Header.Get("Reply-To"); replyTo != "" {
replyDomain := extractDomainFromHeader(replyTo)
if replyDomain != "" && fromDomain != "" && replyDomain != fromDomain {
issues = append(issues, "Reply-To domain differs from From domain")
score += 8
}
}
subject := strings.ToLower(msg.Header.Get("Subject"))
urgencyPhrases := []string{
"urgent", "verify your account", "confirm your", "suspended",
"unusual activity", "act now", "immediately", "security alert",
}
for _, phrase := range urgencyPhrases {
if strings.Contains(subject, phrase) {
issues = append(issues, fmt.Sprintf("urgency language in subject: %q", phrase))
score += 4
break
}
}
result := db.CheckPass
if score > 0 {
result = db.CheckWarn
}
if score >= 20 {
result = db.CheckFail
}
detail := "no header issues found"
if len(issues) > 0 {
detail = strings.Join(issues, "; ")
}
return &StageResult{Stage: s.Name(), Result: result, Score: score, Detail: detail}
}
+159
View File
@@ -0,0 +1,159 @@
package pipeline
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gomail/internal/db"
)
// LLMStage asks a local LLM server for a spam/phishing judgment via the
// OpenAI-compatible /v1/chat/completions endpoint — what llama.cpp's
// server exposes (also what most other local-inference servers converged
// on), so no custom llama.cpp-specific protocol is needed. The model is
// instructed to answer with a single 0-100 integer, parsed directly with
// no JSON-mode/function-calling dependency, since not every local server
// build supports those reliably.
//
// This is deliberately a coarse signal, not a primary verdict: LLM output
// is non-deterministic and shouldn't singlehandedly quarantine mail, so
// its score contribution is capped lower than the deterministic stages
// (SPF/DKIM/DMARC) — see the capping in Run.
type LLMStage struct {
BaseURL string
Model string
Timeout time.Duration
}
func (s *LLMStage) Name() string { return "llm" }
const llmSystemPrompt = `You are a spam and phishing classifier. You will be given the headers and ` +
`body of an email. Respond with ONLY a single integer from 0 to 100 representing how likely ` +
`this email is to be spam, phishing, or malicious — 0 means definitely legitimate, 100 means ` +
`definitely malicious. Do not include any other text, explanation, or punctuation in your response.`
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatCompletionResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
}
// maxScoreContribution caps how much the LLM stage alone can push the
// total score, regardless of what the model returns — see the doc comment
// above for why.
const maxScoreContribution = 30.0
func (s *LLMStage) Run(ctx context.Context, mc *MailContext) *StageResult {
start := time.Now()
result := &StageResult{Stage: s.Name()}
content := mc.RawMessage
const maxContentBytes = 8192
if len(content) > maxContentBytes {
content = content[:maxContentBytes]
}
score, err := s.classify(ctx, string(content))
result.DurationMs = time.Since(start).Milliseconds()
if err != nil {
result.Result = db.CheckError
result.Detail = "LLM classification failed: " + err.Error()
return result
}
scaledScore := (score / 100.0) * maxScoreContribution
result.Score = scaledScore
result.Detail = fmt.Sprintf("LLM raw score=%.0f/100, capped contribution=%.1f", score, scaledScore)
if score >= 70 {
result.Result = db.CheckFail
} else if score >= 40 {
result.Result = db.CheckWarn
} else {
result.Result = db.CheckPass
}
return result
}
func (s *LLMStage) classify(ctx context.Context, content string) (float64, error) {
reqBody := chatCompletionRequest{
Model: s.Model,
Messages: []chatMessage{
{Role: "system", Content: llmSystemPrompt},
{Role: "user", Content: content},
},
}
bodyJSON, err := json.Marshal(reqBody)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.BaseURL+"/v1/chat/completions", bytes.NewReader(bodyJSON))
if err != nil {
return 0, fmt.Errorf("building request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: s.Timeout}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("request to LLM server: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("LLM server returned status %d", resp.StatusCode)
}
var result chatCompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("parsing LLM response: %w", err)
}
if len(result.Choices) == 0 {
return 0, fmt.Errorf("LLM response had no choices")
}
raw := strings.TrimSpace(result.Choices[0].Message.Content)
digits := extractLeadingDigits(raw)
if digits == "" {
return 0, fmt.Errorf("LLM response did not contain a parseable score: %q", raw)
}
score, err := strconv.ParseFloat(digits, 64)
if err != nil {
return 0, fmt.Errorf("parsing score %q: %w", digits, err)
}
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score, nil
}
func extractLeadingDigits(s string) string {
var sb strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
sb.WriteRune(r)
} else if sb.Len() > 0 {
break
}
}
return sb.String()
}
+92
View File
@@ -0,0 +1,92 @@
package pipeline
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"gomail/internal/db"
)
// RspamdStage submits the raw message to rspamd's documented /checkv2 HTTP
// API and maps its score/action into this pipeline's scoring model. No
// third-party rspamd client — a plain POST with the raw RFC 5322 message
// as the body is rspamd's actual documented interface. Off by default —
// only active when config.Pipeline.RspamdURL is set.
type RspamdStage struct {
BaseURL string // e.g. "http://127.0.0.1:11333"
Timeout time.Duration
}
func (s *RspamdStage) Name() string { return "rspamd" }
type rspamdResponse struct {
Score float64 `json:"score"`
RequiredScore float64 `json:"required_score"`
Action string `json:"action"`
Symbols map[string]rspamdSymbol `json:"symbols"`
}
type rspamdSymbol struct {
Score float64 `json:"score"`
Name string `json:"name"`
Options []string `json:"options,omitempty"`
}
func (s *RspamdStage) Run(ctx context.Context, mc *MailContext) *StageResult {
start := time.Now()
result := &StageResult{Stage: s.Name()}
resp, err := s.check(ctx, mc.RawMessage)
result.DurationMs = time.Since(start).Milliseconds()
if err != nil {
result.Result = db.CheckError
result.Detail = "rspamd check failed: " + err.Error()
return result
}
// Translate rspamd's own score onto this pipeline's scale by using its
// score directly — rspamd's score is already meant to be compared
// against thresholds the same way this pipeline's is, so no unit
// conversion trickery, just pass it through.
result.Score = resp.Score
switch resp.Action {
case "reject":
result.Result = db.CheckFail
case "add header", "rewrite subject", "greylist":
result.Result = db.CheckWarn
default:
result.Result = db.CheckPass
}
result.Detail = fmt.Sprintf("rspamd score=%.2f required=%.2f action=%s symbols=%d",
resp.Score, resp.RequiredScore, resp.Action, len(resp.Symbols))
return result
}
func (s *RspamdStage) check(ctx context.Context, raw []byte) (*rspamdResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.BaseURL+"/checkv2", bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
req.Header.Set("Content-Type", "message/rfc822")
client := &http.Client{Timeout: s.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request to rspamd: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("rspamd returned status %d", resp.StatusCode)
}
var result rspamdResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parsing rspamd response: %w", err)
}
return &result, nil
}
+162
View File
@@ -0,0 +1,162 @@
package pipeline
import (
"context"
"fmt"
"net"
"strings"
"gomail/internal/db"
)
type SPFStage struct{}
func (s *SPFStage) Name() string { return "spf" }
func (s *SPFStage) Run(ctx context.Context, mc *MailContext) *StageResult {
if mc.SenderIP == nil {
return &StageResult{Stage: s.Name(), Result: db.CheckError, Detail: "no sender IP available"}
}
domain := mc.MailFromDomain()
if domain == "" {
// Null sender (bounces, MAIL FROM:<>) — SPF simply doesn't apply.
return &StageResult{Stage: s.Name(), Result: db.CheckSkipped, Detail: "null sender, SPF not applicable"}
}
result, detail := checkSPF(ctx, mc.SenderIP, domain)
return &StageResult{Stage: s.Name(), Result: result.check, Score: result.score, Detail: detail}
}
type spfOutcome struct {
check db.CheckResult
score float64
}
func checkSPF(ctx context.Context, senderIP net.IP, domain string) (spfOutcome, string) {
resolver := net.DefaultResolver
txts, err := resolver.LookupTXT(ctx, domain)
if err != nil {
return spfOutcome{db.CheckWarn, 5}, fmt.Sprintf("SPF DNS lookup error for %s: %v", domain, err)
}
var spfRecord string
for _, txt := range txts {
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
spfRecord = txt
break
}
}
if spfRecord == "" {
return spfOutcome{db.CheckWarn, 8}, fmt.Sprintf("no SPF record published for %s", domain)
}
pass, reason := evaluateSPF(ctx, senderIP, domain, spfRecord, 0)
if pass {
return spfOutcome{db.CheckPass, 0}, fmt.Sprintf("SPF pass for %s (%s)", domain, reason)
}
switch {
case strings.Contains(spfRecord, "-all"):
return spfOutcome{db.CheckFail, 25}, fmt.Sprintf("SPF hard fail for %s: %s", domain, reason)
case strings.Contains(spfRecord, "~all"):
return spfOutcome{db.CheckWarn, 10}, fmt.Sprintf("SPF softfail for %s: %s", domain, reason)
default:
return spfOutcome{db.CheckWarn, 5}, fmt.Sprintf("SPF neutral/no-match for %s: %s", domain, reason)
}
}
// evaluateSPF is a pragmatic RFC 7208 evaluator: ip4/ip6/a/mx/include/redirect
// mechanisms, up to 10 levels of recursion (the spec's own limit). It does not
// implement every rarely-used mechanism (ptr, exists) — those are uncommon in
// modern SPF records and can be added later without changing the stage's shape.
func evaluateSPF(ctx context.Context, ip net.IP, domain, record string, depth int) (bool, string) {
if depth > 10 {
return false, "too many SPF redirects/includes"
}
resolver := net.DefaultResolver
for _, tok := range strings.Fields(record)[1:] { // skip "v=spf1"
lower := strings.ToLower(tok)
switch {
case lower == "+all" || lower == "all":
return true, "all"
case lower == "-all" || lower == "~all" || lower == "?all":
return false, "all (no earlier match)"
case strings.HasPrefix(lower, "ip4:"), strings.HasPrefix(lower, "ip6:"):
cidr := tok[strings.Index(tok, ":")+1:]
if matchCIDR(ip, cidr) {
return true, "matched " + tok
}
case strings.HasPrefix(lower, "include:"):
incDomain := tok[len("include:"):]
txts, err := resolver.LookupTXT(ctx, incDomain)
if err == nil {
for _, txt := range txts {
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
if ok, r := evaluateSPF(ctx, ip, incDomain, txt, depth+1); ok {
return true, "include:" + incDomain + " -> " + r
}
break
}
}
}
case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"):
checkDomain := domain
if strings.HasPrefix(lower, "a:") {
checkDomain = tok[len("a:"):]
}
addrs, err := resolver.LookupHost(ctx, checkDomain)
if err == nil {
for _, a := range addrs {
if net.ParseIP(a).Equal(ip) {
return true, "matched a:" + checkDomain
}
}
}
case lower == "mx" || strings.HasPrefix(lower, "mx:"):
checkDomain := domain
if strings.HasPrefix(lower, "mx:") {
checkDomain = tok[len("mx:"):]
}
mxs, err := resolver.LookupMX(ctx, checkDomain)
if err == nil {
for _, mx := range mxs {
addrs, _ := resolver.LookupHost(ctx, mx.Host)
for _, a := range addrs {
if net.ParseIP(a).Equal(ip) {
return true, "matched mx:" + checkDomain
}
}
}
}
case strings.HasPrefix(lower, "redirect="):
redir := tok[len("redirect="):]
txts, err := resolver.LookupTXT(ctx, redir)
if err == nil {
for _, txt := range txts {
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
return evaluateSPF(ctx, ip, redir, txt, depth+1)
}
}
}
}
}
return false, "no mechanism matched"
}
func matchCIDR(ip net.IP, cidr string) bool {
if !strings.Contains(cidr, "/") {
return net.ParseIP(cidr).Equal(ip)
}
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return false
}
return network.Contains(ip)
}
+99
View File
@@ -0,0 +1,99 @@
package pipeline
import (
"context"
"fmt"
"regexp"
"strings"
"gomail/internal/db"
)
type URLStage struct{}
func (s *URLStage) Name() string { return "urls" }
var urlRE = regexp.MustCompile(`https?://[^\s<>"']+`)
var shortenerDomains = []string{
"bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd", "buff.ly", "short.link",
}
var suspiciousTLDs = []string{
".xyz", ".top", ".click", ".work", ".loan", ".gq", ".tk", ".ml",
}
func (s *URLStage) Run(_ context.Context, mc *MailContext) *StageResult {
text := string(mc.RawMessage)
urls := urlRE.FindAllString(text, 50)
if len(urls) == 0 {
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Detail: "no URLs found"}
}
var issues []string
score := 0.0
seen := map[string]bool{}
for _, u := range urls {
u = strings.TrimRight(u, ".,;:!?)'\"")
if seen[u] {
continue
}
seen[u] = true
lower := strings.ToLower(u)
for _, shortener := range shortenerDomains {
if strings.Contains(lower, shortener) {
issues = append(issues, fmt.Sprintf("URL shortener: %s", shortener))
score += 6
break
}
}
for _, tld := range suspiciousTLDs {
if strings.Contains(lower, tld) {
issues = append(issues, fmt.Sprintf("suspicious TLD in URL: %s", u))
score += 4
break
}
}
// IP-address-literal URLs (http://1.2.3.4/...) are a strong phishing
// signal — legitimate mail almost never links directly to a bare IP.
if ipLiteralRE.MatchString(u) {
issues = append(issues, fmt.Sprintf("IP-literal URL: %s", u))
score += 8
}
}
if score > 30 {
score = 30 // cap — URL heuristics alone shouldn't dominate the verdict
}
result := db.CheckPass
if score > 0 {
result = db.CheckWarn
}
if score >= 15 {
result = db.CheckFail
}
detail := fmt.Sprintf("%d unique URL(s) found", len(seen))
if len(issues) > 0 {
detail += ": " + strings.Join(dedupe(issues), "; ")
}
return &StageResult{Stage: s.Name(), Result: result, Score: score, Detail: detail}
}
var ipLiteralRE = regexp.MustCompile(`https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`)
func dedupe(items []string) []string {
seen := map[string]bool{}
var out []string
for _, i := range items {
if !seen[i] {
seen[i] = true
out = append(out, i)
}
}
return out
}
+520
View File
@@ -0,0 +1,520 @@
// Package pop3 implements a minimal POP3 server (RFC 1939 core commands)
// for legacy clients. Off by default — enabled via config.POP3Config.Enabled.
// USER/PASS/STAT/LIST/RETR/DELE/RSET/NOOP/QUIT/UIDL/TOP — no APOP (requires
// storing plaintext-equivalent passwords, which conflicts with bcrypt-only
// storage) and no PIPELINING negotiation (POP3 has none to negotiate; most
// clients pipeline anyway and this server reads one command per line
// regardless).
package pop3
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"log/slog"
"net"
"strconv"
"strings"
"sync"
"time"
"gomail/internal/auth"
"gomail/internal/db"
"gomail/internal/mailstore"
"gomail/internal/ratelimit"
)
const idleTimeout = 10 * time.Minute
type Server struct {
database *db.DB
store *mailstore.Store
tlsConf *tls.Config
hostname string
listeners []net.Listener
wg sync.WaitGroup
sessionWG sync.WaitGroup
authLimiter *ratelimit.Limiter // per-IP PASS failures/min — checked before credential verification
}
func NewServer(database *db.DB, store *mailstore.Store, tlsConf *tls.Config, hostname string, authFailuresPerMin int) *Server {
return &Server{
database: database,
store: store,
tlsConf: tlsConf,
hostname: hostname,
authLimiter: ratelimit.New(authFailuresPerMin),
}
}
func connHost(addr net.Addr) string {
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
func (s *Server) ListenAndServe(ctx context.Context, plainAddr, tlsAddr string) error {
specs := []struct {
addr string
useTLS bool
}{
{plainAddr, false},
{tlsAddr, true},
}
for _, spec := range specs {
ln, err := net.Listen("tcp", spec.addr)
if err != nil {
s.closeAll()
return fmt.Errorf("listen %s: %w", spec.addr, err)
}
if spec.useTLS {
ln = tls.NewListener(ln, s.tlsConf)
}
s.listeners = append(s.listeners, ln)
slog.Info("POP3 listener started", "addr", spec.addr, "implicit_tls", spec.useTLS)
s.wg.Add(1)
go func(ln net.Listener) {
defer s.wg.Done()
s.acceptLoop(ctx, ln)
}(ln)
}
<-ctx.Done()
return ctx.Err()
}
func (s *Server) acceptLoop(ctx context.Context, ln net.Listener) {
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
slog.Error("POP3 accept error", "err", err)
return
}
}
s.sessionWG.Add(1)
go func() {
defer s.sessionWG.Done()
sess := newSession(conn, s)
sess.run(ctx)
}()
}
}
func (s *Server) Shutdown(gracePeriod time.Duration) {
s.closeAll()
done := make(chan struct{})
go func() {
s.sessionWG.Wait()
close(done)
}()
select {
case <-done:
slog.Info("all POP3 sessions drained cleanly")
case <-time.After(gracePeriod):
slog.Warn("POP3 shutdown grace period expired")
}
}
func (s *Server) closeAll() {
for _, ln := range s.listeners {
ln.Close()
}
s.wg.Wait()
}
// ── Session ───────────────────────────────────────────────────────────────────
type pop3State int
const (
popAuthorization pop3State = iota
popTransaction
popUpdate
)
type session struct {
conn net.Conn
rw *bufio.ReadWriter
server *Server
state pop3State
tlsActive bool
user *db.User
pendingUser string // set by USER, consumed by PASS
// Snapshot of INBOX at login — POP3's message numbers are 1-based indexes
// into this snapshot, exactly like IMAP sequence numbers, and marked
// deleted (not removed) until QUIT commits them in the UPDATE state.
entries []db.MailboxEntry
markedDelete map[int]bool
}
func newSession(conn net.Conn, server *Server) *session {
_, isTLS := conn.(*tls.Conn)
return &session{
conn: conn,
rw: bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)),
server: server,
state: popAuthorization,
tlsActive: isTLS,
markedDelete: map[int]bool{},
}
}
func (s *session) run(ctx context.Context) {
s.reply(true, fmt.Sprintf("GoMail POP3 server ready (%s)", s.server.hostname))
for {
select {
case <-ctx.Done():
s.reply(false, "server shutting down")
return
default:
}
s.conn.SetReadDeadline(time.Now().Add(idleTimeout))
line, err := s.rw.ReadString('\n')
if err != nil {
if err != io.EOF {
slog.Debug("POP3 read error", "err", err)
}
return
}
line = strings.TrimRight(line, "\r\n")
if !s.dispatch(line) {
return
}
}
}
func (s *session) dispatch(line string) bool {
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToUpper(parts[0])
arg := ""
if len(parts) > 1 {
arg = parts[1]
}
switch cmd {
case "QUIT":
s.commitDeletes()
s.reply(true, "GoMail POP3 server signing off")
return false
case "USER":
s.cmdUser(arg)
case "PASS":
s.cmdPass(arg)
case "STAT":
s.cmdStat()
case "LIST":
s.cmdList(arg)
case "UIDL":
s.cmdUIDL(arg)
case "RETR":
s.cmdRetr(arg)
case "TOP":
s.cmdTop(arg)
case "DELE":
s.cmdDele(arg)
case "RSET":
s.cmdRset()
case "NOOP":
s.reply(true, "")
default:
s.reply(false, "command not recognized")
}
return true
}
func (s *session) reply(ok bool, msg string) {
prefix := "-ERR"
if ok {
prefix = "+OK"
}
if msg == "" {
s.rw.WriteString(prefix + "\r\n")
} else {
s.rw.WriteString(prefix + " " + msg + "\r\n")
}
s.rw.Flush()
}
// ── Authorization state ────────────────────────────────────────────────────────
func (s *session) cmdUser(arg string) {
if !s.tlsActive {
s.reply(false, "USER over plaintext refused — connect on the implicit-TLS port")
return
}
if s.state != popAuthorization {
s.reply(false, "command not valid in this state")
return
}
s.pendingUser = arg
s.reply(true, "user accepted, send PASS")
}
func (s *session) cmdPass(arg string) {
if !s.tlsActive {
s.reply(false, "PASS over plaintext refused — connect on the implicit-TLS port")
return
}
// Checked before attempting any credential verification — same
// rationale as smtp.session.handleAuth's authLimiter check.
ip := connHost(s.conn.RemoteAddr())
if !s.server.authLimiter.Allow(ip) {
s.reply(false, "too many authentication attempts, try again later")
return
}
if s.state != popAuthorization || s.pendingUser == "" {
s.reply(false, "USER required first")
return
}
user, ok := auth.Authenticate(s.server.database, s.pendingUser, arg, auth.ScopePOP3)
s.pendingUser = ""
if !ok {
s.reply(false, "authentication failed")
return
}
entries, err := s.server.database.ListMailboxEntries(user.ID, "INBOX")
if err != nil {
s.reply(false, "temporary error listing mailbox")
return
}
s.user = user
s.entries = entries
s.state = popTransaction
s.reply(true, fmt.Sprintf("%s's maildrop has %d message(s)", user.Email, len(entries)))
}
// ── Transaction state ────────────────────────────────────────────────────────
func (s *session) cmdStat() {
if !s.requireTransaction() {
return
}
total := int64(0)
count := 0
for i, e := range s.entries {
if s.markedDelete[i+1] {
continue
}
total += e.SizeBytes
count++
}
s.reply(true, fmt.Sprintf("%d %d", count, total))
}
func (s *session) cmdList(arg string) {
if !s.requireTransaction() {
return
}
if arg != "" {
n, err := strconv.Atoi(arg)
if err != nil || n < 1 || n > len(s.entries) || s.markedDelete[n] {
s.reply(false, "no such message")
return
}
s.reply(true, fmt.Sprintf("%d %d", n, s.entries[n-1].SizeBytes))
return
}
s.reply(true, fmt.Sprintf("%d messages", s.liveCount()))
for i, e := range s.entries {
if s.markedDelete[i+1] {
continue
}
s.rw.WriteString(fmt.Sprintf("%d %d\r\n", i+1, e.SizeBytes))
}
s.rw.WriteString(".\r\n")
s.rw.Flush()
}
func (s *session) cmdUIDL(arg string) {
if !s.requireTransaction() {
return
}
if arg != "" {
n, err := strconv.Atoi(arg)
if err != nil || n < 1 || n > len(s.entries) || s.markedDelete[n] {
s.reply(false, "no such message")
return
}
s.reply(true, fmt.Sprintf("%d %s", n, s.entries[n-1].ID))
return
}
s.reply(true, "unique-id listing follows")
for i, e := range s.entries {
if s.markedDelete[i+1] {
continue
}
s.rw.WriteString(fmt.Sprintf("%d %s\r\n", i+1, e.ID))
}
s.rw.WriteString(".\r\n")
s.rw.Flush()
}
func (s *session) cmdRetr(arg string) {
if !s.requireTransaction() {
return
}
n, ok := s.validMessageNum(arg)
if !ok {
return
}
entry := s.entries[n-1]
raw, err := s.server.store.Read(entry.EMLPath)
if err != nil {
s.reply(false, "error reading message")
return
}
s.reply(true, fmt.Sprintf("%d octets", len(raw)))
s.writeDotStuffed(raw)
}
func (s *session) cmdTop(arg string) {
if !s.requireTransaction() {
return
}
parts := strings.SplitN(arg, " ", 2)
if len(parts) != 2 {
s.reply(false, "TOP requires message number and line count")
return
}
n, ok := s.validMessageNum(parts[0])
if !ok {
return
}
nLines, err := strconv.Atoi(parts[1])
if err != nil || nLines < 0 {
s.reply(false, "invalid line count")
return
}
entry := s.entries[n-1]
raw, err := s.server.store.Read(entry.EMLPath)
if err != nil {
s.reply(false, "error reading message")
return
}
headerEnd := strings.Index(string(raw), "\r\n\r\n")
var header, body string
if headerEnd >= 0 {
header = string(raw[:headerEnd+4])
body = string(raw[headerEnd+4:])
} else {
header = string(raw)
}
bodyLines := strings.Split(body, "\r\n")
if nLines > len(bodyLines) {
nLines = len(bodyLines)
}
result := header + strings.Join(bodyLines[:nLines], "\r\n")
s.reply(true, "top of message follows")
s.writeDotStuffed([]byte(result))
}
func (s *session) cmdDele(arg string) {
if !s.requireTransaction() {
return
}
n, ok := s.validMessageNum(arg)
if !ok {
return
}
s.markedDelete[n] = true
s.reply(true, fmt.Sprintf("message %d marked for deletion", n))
}
func (s *session) cmdRset() {
if !s.requireTransaction() {
return
}
s.markedDelete = map[int]bool{}
s.reply(true, "maildrop state reset")
}
// commitDeletes runs at QUIT — actually removes messages marked with DELE,
// per RFC 1939 §5's UPDATE state semantics (deletion is provisional until
// QUIT; RSET or a dropped connection discards the marks instead).
func (s *session) commitDeletes() {
if s.state != popTransaction {
return
}
for i, e := range s.entries {
if s.markedDelete[i+1] {
s.server.database.DeleteMailboxEntry(e.ID)
}
}
s.state = popUpdate
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func (s *session) requireTransaction() bool {
if s.state != popTransaction {
s.reply(false, "command not valid in this state")
return false
}
return true
}
func (s *session) validMessageNum(arg string) (int, bool) {
n, err := strconv.Atoi(arg)
if err != nil || n < 1 || n > len(s.entries) {
s.reply(false, "no such message")
return 0, false
}
if s.markedDelete[n] {
s.reply(false, "message already deleted")
return 0, false
}
return n, true
}
func (s *session) liveCount() int {
c := 0
for i := range s.entries {
if !s.markedDelete[i+1] {
c++
}
}
return c
}
// writeDotStuffed writes a message body with byte-stuffing (a line starting
// with "." gets an extra "." prepended) and the terminating "." line, per
// RFC 1939 §3.
func (s *session) writeDotStuffed(raw []byte) {
lines := strings.Split(string(raw), "\r\n")
for _, line := range lines {
if strings.HasPrefix(line, ".") {
s.rw.WriteString("." + line + "\r\n")
} else {
s.rw.WriteString(line + "\r\n")
}
}
s.rw.WriteString(".\r\n")
s.rw.Flush()
}
+334
View File
@@ -0,0 +1,334 @@
// Package queue implements the outbound delivery worker: polls due entries,
// resolves MX records, delivers via net/smtp (stdlib), and handles retry
// backoff and bounce generation for permanent failures.
package queue
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/smtp"
"strings"
"time"
"gomail/internal/db"
"gomail/internal/dkim"
"gomail/internal/mailstore"
)
const (
maxAttempts = 5
pollInterval = 30 * time.Second
deliveryTimeout = 60 * time.Second
)
// Deliverer is the interface the worker uses to actually hand a message to a
// remote MTA — abstracted so tests can inject a fake without real network
// access (outbound port 25 is blocked in most sandboxed/dev environments).
type Deliverer interface {
Deliver(from, to string, raw []byte) error
}
// KeyLookup resolves the DKIM signing key for a sending domain, returning
// (privateKeyPEM, selector, found). The worker calls this fresh on every
// delivery attempt (not cached at startup) so key rotation via the admin
// portal takes effect immediately without a restart.
type KeyLookup func(fromDomain string) (privateKeyPEM []byte, selector string, ok bool)
// Worker polls outbound_queue and processes due entries.
type Worker struct {
database *db.DB
store *mailstore.Store
deliverer Deliverer
keyLookup KeyLookup
stopCh chan struct{}
}
func NewWorker(database *db.DB, store *mailstore.Store) *Worker {
return &Worker{
database: database,
store: store,
deliverer: &MXDeliverer{Hostname: "gomail"},
stopCh: make(chan struct{}),
}
}
// WithDeliverer overrides the delivery mechanism — used by tests.
func (w *Worker) WithDeliverer(d Deliverer) *Worker {
w.deliverer = d
return w
}
// WithKeyLookup enables DKIM signing before every delivery attempt. Signing
// happens here in the worker — not inside a specific Deliverer implementation
// — so it applies uniformly regardless of transport (MX delivery, a test
// fake, or any future alternative).
func (w *Worker) WithKeyLookup(kl KeyLookup) *Worker {
w.keyLookup = kl
return w
}
// Run starts the polling loop. Blocks until Stop is called.
func (w *Worker) Run() {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
slog.Info("outbound queue worker started", "poll_interval", pollInterval)
w.ProcessOnce() // run immediately on start, don't wait for the first tick
for {
select {
case <-w.stopCh:
return
case <-ticker.C:
w.ProcessOnce()
}
}
}
func (w *Worker) Stop() {
close(w.stopCh)
}
// ProcessOnce runs a single pass: attempts delivery for all due entries,
// then bounces anything that has exhausted its retry budget.
func (w *Worker) ProcessOnce() {
entries, err := w.database.DueOutboundEntries(maxAttempts, 100)
if err != nil {
slog.Error("queue: failed to load due entries", "err", err)
return
}
for _, entry := range entries {
w.attemptDelivery(entry)
}
failed, err := w.database.PermanentlyFailedEntries(maxAttempts)
if err != nil {
slog.Error("queue: failed to load permanently failed entries", "err", err)
return
}
for _, entry := range failed {
w.bounce(entry)
}
}
func (w *Worker) attemptDelivery(entry db.OutboundQueueEntry) {
raw, err := w.store.Read(entry.EMLPath)
if err != nil {
slog.Error("queue: failed to read queued message", "id", entry.ID, "err", err)
w.scheduleRetry(entry, fmt.Sprintf("read failed: %v", err))
return
}
if w.keyLookup != nil {
fromDomain := domainOf(entry.FromAddress)
if privateKeyPEM, selector, ok := w.keyLookup(fromDomain); ok {
signed, err := dkim.Sign(privateKeyPEM, fromDomain, selector, raw)
if err != nil {
slog.Warn("queue: DKIM signing failed, sending unsigned", "domain", fromDomain, "err", err)
} else {
raw = signed
}
}
}
err = w.deliverer.Deliver(entry.FromAddress, entry.ToAddress, raw)
if err == nil {
slog.Info("queue: delivered", "to", entry.ToAddress, "attempts", entry.Attempts+1)
if delErr := w.database.DeleteOutboundEntry(entry.ID); delErr != nil {
slog.Error("queue: failed to delete completed entry", "err", delErr)
}
return
}
if isPermanentError(err) {
slog.Warn("queue: permanent delivery failure, will bounce", "to", entry.ToAddress, "err", err)
// Fast-forward attempts to the max so the next ProcessOnce pass bounces
// it immediately, instead of waiting through the full retry schedule.
remaining := maxAttempts - entry.Attempts
for i := 0; i < remaining; i++ {
w.database.RetryOutboundEntry(entry.ID, time.Now().UTC(), err.Error())
}
return
}
slog.Info("queue: temporary delivery failure, will retry", "to", entry.ToAddress, "attempt", entry.Attempts+1, "err", err)
w.scheduleRetry(entry, err.Error())
}
func (w *Worker) scheduleRetry(entry db.OutboundQueueEntry, errMsg string) {
backoff := backoffDuration(entry.Attempts + 1)
next := time.Now().UTC().Add(backoff)
if err := w.database.RetryOutboundEntry(entry.ID, next, errMsg); err != nil {
slog.Error("queue: failed to schedule retry", "err", err)
}
}
// backoffDuration implements exponential backoff: 5m, 20m, 1h20m, 5h20m, ~21h
// for attempts 1 through 5, capping the total retry window near 5 days as
// planned (RFC 5321 recommends retrying for at least 4-5 days before giving up).
func backoffDuration(attempt int) time.Duration {
base := 5 * time.Minute
d := base
for i := 1; i < attempt; i++ {
d *= 4
}
max := 24 * time.Hour
if d > max {
d = max
}
return d
}
// bounce generates a DSN-style bounce message and delivers it to the local
// sender's INBOX (the original MAIL FROM on submission is always a local
// user, since session.go enforces that match at RCPT TO time).
func (w *Worker) bounce(entry db.OutboundQueueEntry) {
user, err := w.database.LookupUserByEmail(entry.FromAddress)
if err != nil {
slog.Error("queue: cannot bounce — original sender not found locally", "from", entry.FromAddress, "err", err)
w.database.DeleteOutboundEntry(entry.ID)
return
}
bounceBody := fmt.Sprintf(
"From: Mail Delivery System <postmaster@%s>\r\n"+
"To: %s\r\n"+
"Subject: Undelivered Mail Returned to Sender\r\n"+
"Date: %s\r\n"+
"\r\n"+
"This is an automatically generated Delivery Status Notification.\r\n\r\n"+
"Delivery to the following recipient failed permanently after %d attempts:\r\n\r\n"+
" %s\r\n\r\n"+
"Last error: %s\r\n\r\n"+
"This is the final notification; no further attempts will be made.\r\n",
domainOf(entry.FromAddress), entry.FromAddress, time.Now().UTC().Format(time.RFC1123Z),
entry.Attempts, entry.ToAddress, entry.LastError,
)
if _, err := w.store.Deliver(user.ID, user.Email, "INBOX", []byte(bounceBody)); err != nil {
slog.Error("queue: failed to deliver bounce", "err", err)
return
}
slog.Info("queue: bounce delivered", "to", entry.FromAddress, "original_recipient", entry.ToAddress)
w.database.DeleteOutboundEntry(entry.ID)
}
func domainOf(email string) string {
parts := strings.SplitN(email, "@", 2)
if len(parts) == 2 {
return parts[1]
}
return "localhost"
}
// isPermanentError distinguishes 5xx (permanent) from 4xx/network (temporary)
// SMTP failures — net/smtp wraps the server's textual response in the error,
// so we inspect it for the leading status code digit.
func isPermanentError(err error) bool {
msg := err.Error()
// net/smtp errors look like "553 5.1.1 User unknown" when they come from
// the remote server's response.
for _, code := range []string{"550", "551", "552", "553", "554"} {
if strings.Contains(msg, code) {
return true
}
}
return false
}
// ── MX-resolving deliverer (stdlib net/smtp + net.LookupMX) ────────────────────
// MXDeliverer is the real production Deliverer: resolves the recipient
// domain's MX records, connects (with STARTTLS if offered), and hands off
// via net/smtp — Go's standard library SMTP client, chosen specifically to
// stay dependency-free for outbound delivery just as the inbound server is
// hand-rolled from net.Listener. Pure transport — DKIM signing (if any)
// happens in Worker.attemptDelivery before Deliver is called, so it applies
// uniformly regardless of which Deliverer implementation is in use.
type MXDeliverer struct {
Hostname string // EHLO identity
}
func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
domain := domainOf(to)
mxHosts, err := lookupMXHosts(domain)
if err != nil {
return fmt.Errorf("451 4.4.3 MX lookup failed for %s: %w", domain, err)
}
var lastErr error
for _, host := range mxHosts {
if err := d.deliverToHost(host, from, to, raw); err != nil {
lastErr = err
continue
}
return nil
}
return lastErr
}
func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
conn, err := net.DialTimeout("tcp", host+":25", deliveryTimeout)
if err != nil {
return fmt.Errorf("421 4.4.1 connect to %s failed: %w", host, err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(deliveryTimeout))
client, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("421 4.4.1 SMTP handshake with %s failed: %w", host, err)
}
defer client.Close()
if err := client.Hello(d.Hostname); err != nil {
return fmt.Errorf("EHLO to %s failed: %w", host, err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
tlsConf := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
if err := client.StartTLS(tlsConf); err != nil {
slog.Warn("STARTTLS failed, continuing without encryption", "host", host, "err", err)
}
}
if err := client.Mail(from); err != nil {
return err // preserves the remote server's status code in the error text
}
if err := client.Rcpt(to); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
if _, err := w.Write(raw); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}
func lookupMXHosts(domain string) ([]string, error) {
mxs, err := net.LookupMX(domain)
if err != nil || len(mxs) == 0 {
// RFC 5321 §5.1 fallback: if no MX records, try the domain's A record directly.
if _, aErr := net.LookupHost(domain); aErr == nil {
return []string{domain}, nil
}
return nil, fmt.Errorf("no MX or A record for %s: %w", domain, err)
}
hosts := make([]string, len(mxs))
for i, mx := range mxs {
hosts[i] = strings.TrimSuffix(mx.Host, ".")
}
return hosts, nil
}
+42
View File
@@ -0,0 +1,42 @@
package ratelimit
import (
"net"
"net/http"
"strings"
)
// HTTPMiddleware wraps next with per-client-IP rate limiting, returning 429
// for requests over the limit. realIPHeader (e.g. "X-Forwarded-For"), if
// non-empty, is trusted for the client IP instead of RemoteAddr — only set
// this when the server is genuinely behind a reverse proxy that sets it;
// trusting it otherwise lets any client spoof their rate-limit identity.
func (l *Limiter) HTTPMiddleware(realIPHeader string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := ClientIP(r, realIPHeader)
if !l.Allow(ip) {
w.Header().Set("Retry-After", "60")
http.Error(w, "rate limit exceeded, try again shortly", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// ClientIP resolves the request's client IP the same way HTTPMiddleware
// does, for callers that need it outside a rate-limit context (e.g. an IP
// allowlist middleware). See HTTPMiddleware's doc comment for the
// realIPHeader trust caveat.
func ClientIP(r *http.Request, realIPHeader string) string {
if realIPHeader != "" {
if v := r.Header.Get(realIPHeader); v != "" {
parts := strings.Split(v, ",")
return strings.TrimSpace(parts[0])
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
+109
View File
@@ -0,0 +1,109 @@
// Package ratelimit implements a per-key token-bucket rate limiter — no
// third-party rate-limiting library. A token bucket (rather than a hard
// fixed-window reset) is used deliberately: it smooths out bursts at
// window boundaries that a naive "reset every 60s" counter would allow
// (e.g. 20 requests at 0:59 plus another 20 at 1:01 both passing a
// "20/min" limit reset at the minute boundary). Safe for concurrent use.
package ratelimit
import (
"sync"
"time"
)
type bucket struct {
tokens float64
lastRefill time.Time
}
// Limiter enforces "at most ratePerMinute events per key, per minute" with
// burst tolerance up to ratePerMinute tokens banked at once (i.e. a key
// that's been idle can burst up to the full per-minute allowance instantly,
// then is throttled to the steady-state rate — standard token-bucket
// behavior, not a stricter "evenly spaced" enforcement).
type Limiter struct {
ratePerMinute float64
mu sync.Mutex
buckets map[string]*bucket
stopCleanup chan struct{}
}
// New creates a limiter allowing ratePerMinute events per key. Pass 0 to
// disable limiting entirely (Allow always returns true) — this is how a
// zero/unset config value opts a listener out of rate limiting rather than
// silently blocking everything.
func New(ratePerMinute int) *Limiter {
l := &Limiter{
ratePerMinute: float64(ratePerMinute),
buckets: make(map[string]*bucket),
stopCleanup: make(chan struct{}),
}
if ratePerMinute > 0 {
go l.cleanupLoop()
}
return l
}
// Allow reports whether an event for key is permitted right now, consuming
// one token if so. Safe to call from many goroutines concurrently.
func (l *Limiter) Allow(key string) bool {
if l.ratePerMinute <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.buckets[key]
if !ok {
b = &bucket{tokens: l.ratePerMinute - 1, lastRefill: now}
l.buckets[key] = b
return true
}
elapsed := now.Sub(b.lastRefill).Seconds()
refill := elapsed * (l.ratePerMinute / 60.0)
b.tokens += refill
if b.tokens > l.ratePerMinute {
b.tokens = l.ratePerMinute
}
b.lastRefill = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// cleanupLoop periodically evicts buckets idle long enough to have fully
// refilled, so a limiter tracking many distinct one-off IPs doesn't grow
// unboundedly over a long-running server's lifetime.
func (l *Limiter) cleanupLoop() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
select {
case <-l.stopCleanup:
return
case <-ticker.C:
l.mu.Lock()
now := time.Now()
for key, b := range l.buckets {
if now.Sub(b.lastRefill) > 30*time.Minute {
delete(l.buckets, key)
}
}
l.mu.Unlock()
}
}
}
// Stop releases the background cleanup goroutine.
func (l *Limiter) Stop() {
if l.ratePerMinute > 0 {
close(l.stopCleanup)
}
}
+105
View File
@@ -0,0 +1,105 @@
package sieve
import "strings"
// Result is the outcome of running a script against one message.
type Result struct {
Action string // "fileinto" | "discard" | "keep" (default if nothing else fired)
Folder string // set only when Action == "fileinto"
}
// Execute runs script against the given headers (case-insensitive header
// names, matching real email header semantics) and returns the first
// decisive action encountered. "stop" halts execution immediately with
// whatever result has accumulated so far. If no action fires, the default
// result is "keep" (deliver to INBOX), matching RFC 5228 §2.10's implicit
// keep behavior.
//
// Simplification: real Sieve treats keep/fileinto/discard as an
// accumulating SET of actions (a message can be filed into a folder AND
// kept in INBOX, for instance) — this implementation tracks only the single
// most recent action instead, last-one-wins. This still matches RFC 5228's
// core rule that "discard cancels the implicit keep, but an explicit keep
// after it still delivers" (§4.4) — a discard followed by an unconditional
// keep with no stop in between DOES deliver the message, correctly. What's
// NOT supported is a script that intends both fileinto AND keep to fire
// simultaneously (message copied to a folder AND left in INBOX) — write
// "stop;" after the decisive action if that's not the intended behavior,
// same as real Sieve authors are advised to do to avoid ambiguity.
func Execute(script *Script, headers map[string]string) Result {
result := Result{Action: "keep"}
execStatements(script.Statements, headers, &result)
return result
}
// execStatements returns true if execution should stop (a "stop" action fired).
func execStatements(stmts []Statement, headers map[string]string, result *Result) bool {
for _, stmt := range stmts {
switch s := stmt.(type) {
case Action:
switch s.Name {
case "fileinto":
result.Action = "fileinto"
result.Folder = s.Arg
case "discard":
result.Action = "discard"
case "keep":
result.Action = "keep"
case "stop":
return true
}
case IfStatement:
if evalTest(s.Test, headers) {
if execStatements(s.Then, headers, result) {
return true
}
continue
}
matched := false
for _, ei := range s.ElseIfs {
if evalTest(ei.Test, headers) {
matched = true
if execStatements(ei.Then, headers, result) {
return true
}
break
}
}
if !matched && s.HasElse {
if execStatements(s.Else, headers, result) {
return true
}
}
}
}
return false
}
func evalTest(t Test, headers map[string]string) bool {
switch t.Kind {
case "true":
return true
case "header":
actual, ok := lookupHeader(headers, t.Header)
if !ok {
return false
}
switch t.MatchType {
case "contains":
return strings.Contains(strings.ToLower(actual), strings.ToLower(t.Value))
case "is":
return strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(t.Value))
}
}
return false
}
func lookupHeader(headers map[string]string, name string) (string, bool) {
// Case-insensitive lookup — email headers are case-insensitive per RFC 5322.
for k, v := range headers {
if strings.EqualFold(k, name) {
return v, true
}
}
return "", false
}
+131
View File
@@ -0,0 +1,131 @@
// Package sieve implements a Sieve (RFC 5228) interpreter covering the
// common mail-filtering subset: header tests (:contains, :is), if/elsif/else,
// and the fileinto/discard/keep/stop actions. Not full RFC 5228 — no
// extensions (vacation, reject, notify), no envelope/size/address tests,
// no allof/anyof boolean combinators. This covers what real users actually
// write for "move mail matching X to folder Y" / "discard mail from Z",
// which is the overwhelming majority of real-world Sieve scripts; broader
// grammar support is a natural follow-up once client compatibility testing
// calls for it.
package sieve
import (
"fmt"
"strings"
"unicode"
)
type tokenKind int
const (
tokIdent tokenKind = iota
tokString
tokTag // :contains, :is, etc.
tokSemicolon
tokLBrace
tokRBrace
tokEOF
)
type token struct {
kind tokenKind
value string
}
type lexer struct {
input []rune
pos int
}
func newLexer(script string) *lexer {
return &lexer{input: []rune(script)}
}
func (l *lexer) next() (token, error) {
l.skipWhitespaceAndComments()
if l.pos >= len(l.input) {
return token{kind: tokEOF}, nil
}
c := l.input[l.pos]
switch {
case c == ';':
l.pos++
return token{kind: tokSemicolon, value: ";"}, nil
case c == '{':
l.pos++
return token{kind: tokLBrace, value: "{"}, nil
case c == '}':
l.pos++
return token{kind: tokRBrace, value: "}"}, nil
case c == '"':
return l.readString()
case c == ':':
return l.readTag()
case unicode.IsLetter(c):
return l.readIdent()
default:
return token{}, fmt.Errorf("unexpected character %q at position %d", c, l.pos)
}
}
func (l *lexer) skipWhitespaceAndComments() {
for l.pos < len(l.input) {
c := l.input[l.pos]
if unicode.IsSpace(c) {
l.pos++
continue
}
// Single-line comment: # ... end of line
if c == '#' {
for l.pos < len(l.input) && l.input[l.pos] != '\n' {
l.pos++
}
continue
}
// Bracketed comment: /* ... */
if c == '/' && l.pos+1 < len(l.input) && l.input[l.pos+1] == '*' {
l.pos += 2
for l.pos+1 < len(l.input) && !(l.input[l.pos] == '*' && l.input[l.pos+1] == '/') {
l.pos++
}
l.pos += 2
continue
}
break
}
}
func (l *lexer) readString() (token, error) {
l.pos++ // skip opening quote
var sb strings.Builder
for l.pos < len(l.input) && l.input[l.pos] != '"' {
if l.input[l.pos] == '\\' && l.pos+1 < len(l.input) {
l.pos++
}
sb.WriteRune(l.input[l.pos])
l.pos++
}
if l.pos >= len(l.input) {
return token{}, fmt.Errorf("unterminated string literal")
}
l.pos++ // skip closing quote
return token{kind: tokString, value: sb.String()}, nil
}
func (l *lexer) readTag() (token, error) {
start := l.pos
l.pos++ // skip ':'
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || l.input[l.pos] == '-') {
l.pos++
}
return token{kind: tokTag, value: string(l.input[start:l.pos])}, nil
}
func (l *lexer) readIdent() (token, error) {
start := l.pos
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || unicode.IsDigit(l.input[l.pos]) || l.input[l.pos] == '_') {
l.pos++
}
return token{kind: tokIdent, value: string(l.input[start:l.pos])}, nil
}
+229
View File
@@ -0,0 +1,229 @@
package sieve
import "fmt"
// ── AST ───────────────────────────────────────────────────────────────────────
type Script struct {
Statements []Statement
}
// Statement is either an Action or an IfStatement.
type Statement interface{ isStatement() }
type Action struct {
Name string // "fileinto" | "discard" | "keep" | "stop"
Arg string // folder name for fileinto, empty otherwise
}
func (Action) isStatement() {}
type IfStatement struct {
Test Test
Then []Statement
ElseIfs []ElseIf
Else []Statement
HasElse bool
}
func (IfStatement) isStatement() {}
type ElseIf struct {
Test Test
Then []Statement
}
// Test is a condition — this pass supports only header tests, the
// overwhelming majority of real-world filtering rules.
type Test struct {
Kind string // "header" | "true"
MatchType string // "contains" | "is"
Header string
Value string
}
// ── Parser ────────────────────────────────────────────────────────────────────
type parser struct {
lex *lexer
cur token
}
func Parse(script string) (*Script, error) {
p := &parser{lex: newLexer(script)}
if err := p.advance(); err != nil {
return nil, err
}
s := &Script{}
for p.cur.kind != tokEOF {
stmt, err := p.parseStatement()
if err != nil {
return nil, err
}
s.Statements = append(s.Statements, stmt)
}
return s, nil
}
func (p *parser) advance() error {
t, err := p.lex.next()
if err != nil {
return err
}
p.cur = t
return nil
}
func (p *parser) expect(kind tokenKind, desc string) (token, error) {
if p.cur.kind != kind {
return token{}, fmt.Errorf("expected %s, got %q", desc, p.cur.value)
}
t := p.cur
if err := p.advance(); err != nil {
return token{}, err
}
return t, nil
}
func (p *parser) parseStatement() (Statement, error) {
if p.cur.kind != tokIdent {
return nil, fmt.Errorf("expected statement, got %q", p.cur.value)
}
switch p.cur.value {
case "if":
return p.parseIf()
case "fileinto":
if err := p.advance(); err != nil {
return nil, err
}
arg, err := p.expect(tokString, "folder name")
if err != nil {
return nil, err
}
if _, err := p.expect(tokSemicolon, ";"); err != nil {
return nil, err
}
return Action{Name: "fileinto", Arg: arg.value}, nil
case "discard", "keep", "stop":
name := p.cur.value
if err := p.advance(); err != nil {
return nil, err
}
if _, err := p.expect(tokSemicolon, ";"); err != nil {
return nil, err
}
return Action{Name: name}, nil
default:
return nil, fmt.Errorf("unsupported command %q", p.cur.value)
}
}
func (p *parser) parseIf() (Statement, error) {
if err := p.advance(); err != nil { // skip "if"
return nil, err
}
test, err := p.parseTest()
if err != nil {
return nil, err
}
then, err := p.parseBlock()
if err != nil {
return nil, err
}
stmt := IfStatement{Test: test, Then: then}
for p.cur.kind == tokIdent && p.cur.value == "elsif" {
if err := p.advance(); err != nil {
return nil, err
}
elifTest, err := p.parseTest()
if err != nil {
return nil, err
}
elifThen, err := p.parseBlock()
if err != nil {
return nil, err
}
stmt.ElseIfs = append(stmt.ElseIfs, ElseIf{Test: elifTest, Then: elifThen})
}
if p.cur.kind == tokIdent && p.cur.value == "else" {
if err := p.advance(); err != nil {
return nil, err
}
elseBlock, err := p.parseBlock()
if err != nil {
return nil, err
}
stmt.Else = elseBlock
stmt.HasElse = true
}
return stmt, nil
}
func (p *parser) parseTest() (Test, error) {
if p.cur.kind != tokIdent {
return Test{}, fmt.Errorf("expected test, got %q", p.cur.value)
}
if p.cur.value == "true" {
if err := p.advance(); err != nil {
return Test{}, err
}
return Test{Kind: "true"}, nil
}
if p.cur.value != "header" {
return Test{}, fmt.Errorf("unsupported test %q (only 'header' and 'true' supported)", p.cur.value)
}
if err := p.advance(); err != nil {
return Test{}, err
}
if p.cur.kind != tokTag {
return Test{}, fmt.Errorf("expected match type (:contains or :is), got %q", p.cur.value)
}
matchType := p.cur.value[1:] // strip leading ':'
if matchType != "contains" && matchType != "is" {
return Test{}, fmt.Errorf("unsupported match type %q (only :contains and :is supported)", matchType)
}
if err := p.advance(); err != nil {
return Test{}, err
}
headerTok, err := p.expect(tokString, "header name")
if err != nil {
return Test{}, err
}
valueTok, err := p.expect(tokString, "match value")
if err != nil {
return Test{}, err
}
return Test{Kind: "header", MatchType: matchType, Header: headerTok.value, Value: valueTok.value}, nil
}
func (p *parser) parseBlock() ([]Statement, error) {
if _, err := p.expect(tokLBrace, "{"); err != nil {
return nil, err
}
var stmts []Statement
for p.cur.kind != tokRBrace {
if p.cur.kind == tokEOF {
return nil, fmt.Errorf("unterminated block, expected }")
}
stmt, err := p.parseStatement()
if err != nil {
return nil, err
}
stmts = append(stmts, stmt)
}
if _, err := p.expect(tokRBrace, "}"); err != nil {
return nil, err
}
return stmts, nil
}
+32
View File
@@ -0,0 +1,32 @@
package sieve
import "testing"
func FuzzParse(f *testing.F) {
f.Add(`if header :contains "subject" "invoice" { fileinto "Invoices"; stop; }`)
f.Add(`if header :is "from" "boss@example.com" { fileinto "Important"; } elsif header :contains "subject" "urgent" { fileinto "Important"; } else { keep; }`)
f.Add("")
f.Add("keep;")
f.Add("if true { discard; }")
f.Add(`if header :contains "subject" { fileinto "X" }`)
f.Add("if header { }")
f.Add("{{{{{{{")
f.Add(`if header :contains "a" "b`)
f.Add("if header :bogus \"x\" \"y\" { keep; }")
f.Add("fileinto;")
f.Fuzz(func(t *testing.T, data string) {
// This is the fuzz target most directly exposed to untrusted input
// in production — every ManageSieve PUTSCRIPT is parsed by this
// exact function before storage. A crash here would be a remotely
// triggerable DoS against an authenticated user's own session, so
// "never panics" matters more here than for the calendar/contact
// parsers.
defer func() {
if r := recover(); r != nil {
t.Fatalf("Parse panicked on input %q: %v", data, r)
}
}()
Parse(data)
})
}
+11
View File
@@ -0,0 +1,11 @@
package smtp
import (
"gomail/internal/auth"
"gomail/internal/db"
)
// authenticate is a thin wrapper over the shared auth package, scoped to SMTP.
func authenticate(database *db.DB, username, password string) (*db.User, bool) {
return auth.Authenticate(database, username, password, auth.ScopeSMTP)
}
+212
View File
@@ -0,0 +1,212 @@
// Package smtp implements the inbound SMTP MTA (port 25), submission
// (port 587, STARTTLS + AUTH), and implicit-TLS SMTPS (port 465) — all as a
// single hand-rolled state machine over net.Listener, per the project's
// stdlib-first principle. No third-party SMTP library.
package smtp
import (
"context"
"crypto/tls"
"fmt"
"log/slog"
"net"
"sync"
"time"
"gomail/internal/config"
"gomail/internal/db"
"gomail/internal/mailstore"
"gomail/internal/pipeline"
"gomail/internal/ratelimit"
)
const (
maxCommandLine = 1000 // RFC 5321 command line limit
maxRecipients = 100
idleTimeout = 5 * time.Minute
dataTimeout = 10 * time.Minute
)
// Kind distinguishes the three listener roles — they share the same session
// state machine but differ in whether TLS is implicit, STARTTLS-capable, or
// plain (inbound MTA still offers STARTTLS, just doesn't require it for the
// initial MAIL FROM the way submission does).
type Kind int
const (
KindMTA Kind = iota // :25 — inbound from the internet, STARTTLS optional
KindSubmission // :587 — STARTTLS + AUTH required before MAIL FROM
KindImplicitTLS // :465 — TLS from the first byte
)
type Server struct {
cfg *config.Config
database *db.DB
store *mailstore.Store
tlsConf *tls.Config
pipeline *pipeline.Orchestrator // nil = pipeline disabled, all mail treated as clean
listeners []net.Listener
wg sync.WaitGroup
sessionWG sync.WaitGroup // tracks in-flight sessions for graceful drain
maxMessageBytes int64
connLimiter *ratelimit.Limiter // per-IP connections/min, cfg.RateLimits.SMTPConnPerMin
authLimiter *ratelimit.Limiter // per-IP AUTH failures, cfg.RateLimits.SMTPAuthFailures (per minute)
}
func NewServer(cfg *config.Config, database *db.DB, store *mailstore.Store, tlsConf *tls.Config, orch *pipeline.Orchestrator) *Server {
return &Server{
cfg: cfg,
database: database,
store: store,
tlsConf: tlsConf,
pipeline: orch,
maxMessageBytes: int64(cfg.Storage.MaxMessageSizeMB) * 1024 * 1024,
connLimiter: ratelimit.New(cfg.RateLimits.SMTPConnPerMin),
authLimiter: ratelimit.New(cfg.RateLimits.SMTPAuthFailures),
}
}
// ListenAndServe starts all three listeners and blocks until one fails or
// ctx is cancelled. Each listener's accept loop runs in its own goroutine.
func (s *Server) ListenAndServe(ctx context.Context) error {
specs := []struct {
addr string
kind Kind
}{
{s.cfg.Server.SMTPAddr, KindMTA},
{s.cfg.Server.SubmissionAddr, KindSubmission},
{s.cfg.Server.SMTPSAddr, KindImplicitTLS},
}
errCh := make(chan error, len(specs))
for _, spec := range specs {
ln, err := net.Listen("tcp", spec.addr)
if err != nil {
s.closeAll()
return fmt.Errorf("listen %s: %w", spec.addr, err)
}
if spec.kind == KindImplicitTLS {
ln = tls.NewListener(ln, s.tlsConf)
}
s.listeners = append(s.listeners, ln)
slog.Info("SMTP listener started", "addr", spec.addr, "kind", kindName(spec.kind))
s.wg.Add(1)
go func(ln net.Listener, kind Kind) {
defer s.wg.Done()
s.acceptLoop(ctx, ln, kind)
}(ln, spec.kind)
}
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errCh:
return err
}
}
func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, kind Kind) {
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return // expected — listener closed during shutdown
default:
slog.Error("accept error", "err", err, "kind", kindName(kind))
return
}
}
ip := connHost(conn.RemoteAddr())
if !s.connLimiter.Allow(ip) {
slog.Warn("SMTP connection rate limit exceeded, rejecting", "ip", ip, "kind", kindName(kind))
conn.Close()
continue
}
s.sessionWG.Add(1)
go func() {
defer s.sessionWG.Done()
s.handleConn(ctx, conn, kind)
}()
}
}
func (s *Server) handleConn(ctx context.Context, conn net.Conn, kind Kind) {
defer conn.Close()
sess := &session{
conn: conn,
server: s,
kind: kind,
hostname: s.cfg.Server.Hostname,
}
remoteAddr := conn.RemoteAddr()
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
sess.senderIP = tcpAddr.IP
}
slog.Debug("SMTP connection accepted", "remote", remoteAddr, "kind", kindName(kind))
sess.run(ctx)
}
// Shutdown closes all listeners immediately (stops accepting new
// connections) then waits up to gracePeriod for in-flight sessions to finish
// naturally (they'll see ctx.Done() and wind down at their next command read).
func (s *Server) Shutdown(gracePeriod time.Duration) {
s.closeAll()
done := make(chan struct{})
go func() {
s.sessionWG.Wait()
close(done)
}()
select {
case <-done:
slog.Info("all SMTP sessions drained cleanly")
case <-time.After(gracePeriod):
slog.Warn("SMTP shutdown grace period expired — some sessions forcibly terminated", "grace_period", gracePeriod)
}
}
func (s *Server) closeAll() {
for _, ln := range s.listeners {
ln.Close()
}
s.wg.Wait()
}
func kindName(k Kind) string {
switch k {
case KindMTA:
return "mta"
case KindSubmission:
return "submission"
case KindImplicitTLS:
return "smtps"
default:
return "unknown"
}
}
// connHost extracts just the IP (no port) from a net.Addr, for use as a
// rate-limiter key — falls back to the full address string if it isn't
// host:port shaped (shouldn't happen for real TCP connections, but a
// fallback beats a panic).
func connHost(addr net.Addr) string {
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
+762
View File
@@ -0,0 +1,762 @@
package smtp
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"log/slog"
"net"
"net/mail"
"strings"
"time"
"gomail/internal/db"
"gomail/internal/pipeline"
"gomail/internal/sieve"
"github.com/google/uuid"
)
type state int
const (
stateGreeted state = iota
stateAuthenticated
stateMailFrom
stateRcptTo
)
type session struct {
conn net.Conn
rw *bufio.ReadWriter
server *Server
kind Kind
hostname string
senderIP net.IP
senderHost string
state state
tlsActive bool
authUser *db.User
mailFrom string
rcptTo []string
recipientsValid []recipientTarget
}
type recipientTarget struct {
address string
user *db.User // nil if only validated as accept-all domain (no specific mailbox yet resolvable)
tenantID string
}
func (s *session) isSubmissionKind() bool {
return s.kind == KindSubmission || s.kind == KindImplicitTLS
}
func (s *session) run(ctx context.Context) {
s.rw = bufio.NewReadWriter(bufio.NewReader(s.conn), bufio.NewWriter(s.conn))
if s.kind == KindImplicitTLS {
s.tlsActive = true // listener already wrapped with tls.NewListener
}
s.writeLine(fmt.Sprintf("220 %s GoMail ESMTP ready", s.hostname))
for {
select {
case <-ctx.Done():
s.writeLine("421 4.3.2 Server shutting down")
return
default:
}
s.conn.SetReadDeadline(time.Now().Add(idleTimeout))
line, err := s.readLine()
if err != nil {
if err != io.EOF {
slog.Debug("SMTP read error", "err", err)
}
return
}
if !s.handleCommand(ctx, line) {
return // QUIT or fatal error
}
}
}
// handleCommand dispatches one command line. Returns false if the session
// should close (QUIT or unrecoverable error).
func (s *session) handleCommand(ctx context.Context, line string) bool {
if len(line) > maxCommandLine {
s.writeLine("500 5.5.2 Line too long")
return true
}
verb, rest := splitVerb(line)
switch strings.ToUpper(verb) {
case "HELO":
s.handleHelo(rest, false)
case "EHLO":
s.handleHelo(rest, true)
case "STARTTLS":
s.handleStartTLS()
case "AUTH":
s.handleAuth(rest)
case "MAIL":
s.handleMailFrom(rest)
case "RCPT":
s.handleRcptTo(rest)
case "DATA":
s.handleData(ctx)
case "RSET":
s.reset()
s.writeLine("250 2.0.0 OK")
case "NOOP":
s.writeLine("250 2.0.0 OK")
case "QUIT":
s.writeLine(fmt.Sprintf("221 2.0.0 %s closing connection", s.hostname))
return false
case "VRFY", "EXPN":
// Information disclosure — always decline, never confirm/deny addresses.
s.writeLine("252 2.5.2 Cannot VRFY user, but will accept message and attempt delivery")
default:
s.writeLine("500 5.5.1 Command not recognized")
}
return true
}
func (s *session) handleHelo(arg string, extended bool) {
if arg == "" {
s.writeLine("501 5.5.4 HELO/EHLO requires a hostname argument")
return
}
s.reset()
s.state = stateGreeted
if !extended {
s.writeLine(fmt.Sprintf("250 %s", s.hostname))
return
}
caps := []string{
fmt.Sprintf("250-%s", s.hostname),
"250-PIPELINING",
fmt.Sprintf("250-SIZE %d", s.server.maxMessageBytes),
"250-8BITMIME",
}
if !s.tlsActive {
caps = append(caps, "250-STARTTLS")
}
if s.isSubmissionKind() && s.tlsActive {
caps = append(caps, "250-AUTH PLAIN LOGIN")
}
caps = append(caps, "250 ENHANCEDSTATUSCODES")
for _, c := range caps {
s.writeLine(c)
}
}
func (s *session) handleStartTLS() {
if s.tlsActive {
s.writeLine("503 5.5.1 TLS already active")
return
}
s.writeLine("220 2.0.0 Ready to start TLS")
tlsConn := tls.Server(s.conn, s.server.tlsConf)
if err := tlsConn.HandshakeContext(context.Background()); err != nil {
slog.Debug("STARTTLS handshake failed", "err", err)
return
}
s.conn = tlsConn
s.rw = bufio.NewReadWriter(bufio.NewReader(tlsConn), bufio.NewWriter(tlsConn))
s.tlsActive = true
s.reset() // RFC 3207 — discard any prior state after STARTTLS
s.state = stateGreeted
}
// handleAuth implements SASL PLAIN and LOGIN. Verifies against either the
// user's main password (bcrypt) or an active, non-expired app password
// scoped for "smtp". Submission (:587) requires TLS to be active first.
func (s *session) handleAuth(arg string) {
if s.kind != KindSubmission && s.kind != KindImplicitTLS {
s.writeLine("503 5.5.1 AUTH not permitted on this port")
return
}
if !s.tlsActive {
s.writeLine("538 5.7.11 Encryption required for requested authentication mechanism")
return
}
// Checked before attempting any credential parsing — an IP that has
// already exhausted its allowance shouldn't get free password-guessing
// attempts just because the failure hasn't been recorded yet.
ip := connHost(s.conn.RemoteAddr())
if !s.server.authLimiter.Allow(ip) {
slog.Warn("SMTP AUTH rate limit exceeded", "remote", ip)
s.writeLine("454 4.7.0 Too many authentication attempts, try again later")
return
}
mechanism, initialResponse, _ := strings.Cut(arg, " ")
mechanism = strings.ToUpper(mechanism)
var username, password string
var ok bool
switch mechanism {
case "PLAIN":
username, password, ok = s.readAuthPlain(initialResponse)
case "LOGIN":
username, password, ok = s.readAuthLogin()
default:
s.writeLine("504 5.5.4 Unrecognized authentication mechanism")
return
}
if !ok {
s.writeLine("501 5.5.4 Malformed authentication response")
return
}
user, verified := authenticate(s.server.database, username, password)
if !verified {
slog.Info("SMTP auth failed", "user", username, "remote", s.senderIP)
s.writeLine("535 5.7.8 Authentication credentials invalid")
return
}
s.authUser = user
s.state = stateAuthenticated
s.writeLine("235 2.7.0 Authentication successful")
}
func (s *session) readAuthPlain(initial string) (username, password string, ok bool) {
raw := initial
if raw == "" {
s.writeLine("334 ")
line, err := s.readLine()
if err != nil {
return "", "", false
}
raw = line
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return "", "", false
}
// SASL PLAIN format: authzid\0authcid\0password
parts := strings.SplitN(string(decoded), "\x00", 3)
if len(parts) != 3 {
return "", "", false
}
return parts[1], parts[2], true
}
func (s *session) readAuthLogin() (username, password string, ok bool) {
s.writeLine("334 VXNlcm5hbWU6") // "Username:"
uLine, err := s.readLine()
if err != nil {
return "", "", false
}
uDecoded, err := base64.StdEncoding.DecodeString(uLine)
if err != nil {
return "", "", false
}
s.writeLine("334 UGFzc3dvcmQ6") // "Password:"
pLine, err := s.readLine()
if err != nil {
return "", "", false
}
pDecoded, err := base64.StdEncoding.DecodeString(pLine)
if err != nil {
return "", "", false
}
return string(uDecoded), string(pDecoded), true
}
func (s *session) handleMailFrom(arg string) {
if s.isSubmissionKind() && s.state != stateAuthenticated {
s.writeLine("530 5.7.0 Authentication required")
return
}
addr, ok := parseMailCmdArg(arg, "FROM:")
if !ok {
s.writeLine("501 5.5.4 Syntax error in MAIL FROM command")
return
}
// Submission: envelope sender must match the authenticated user (or their alias).
if s.isSubmissionKind() && addr != "" {
if !strings.EqualFold(addr, s.authUser.Email) {
s.writeLine("553 5.7.1 MAIL FROM must match authenticated identity")
return
}
}
s.mailFrom = strings.ToLower(addr)
s.rcptTo = nil
s.recipientsValid = nil
s.state = stateMailFrom
s.writeLine("250 2.1.0 OK")
}
func (s *session) handleRcptTo(arg string) {
if s.state != stateMailFrom && s.state != stateRcptTo {
s.writeLine("503 5.5.1 MAIL FROM required before RCPT TO")
return
}
if len(s.rcptTo) >= maxRecipients {
s.writeLine("452 4.5.3 Too many recipients")
return
}
addr, ok := parseMailCmdArg(arg, "TO:")
if !ok || addr == "" {
s.writeLine("501 5.5.4 Syntax error in RCPT TO command")
return
}
addr = strings.ToLower(addr)
parts := strings.SplitN(addr, "@", 2)
if len(parts) != 2 {
s.writeLine("501 5.1.3 Bad recipient address syntax")
return
}
domainPart := parts[1]
// Outbound relay (submission, authenticated) — recipient is external, no local check.
if s.isSubmissionKind() && s.authUser != nil {
s.rcptTo = append(s.rcptTo, addr)
s.recipientsValid = append(s.recipientsValid, recipientTarget{address: addr, tenantID: s.authUser.TenantID})
s.state = stateRcptTo
s.writeLine("250 2.1.5 OK")
return
}
// Inbound — recipient must be a hosted domain, and either accept-all or a known user.
domain, tenant, err := s.server.database.LookupDomain(domainPart)
if err != nil {
slog.Debug("RCPT rejected — unknown domain", "domain", domainPart)
s.writeLine("550 5.1.2 Bad destination mailbox address")
return
}
// Sender IP/address block-list check.
senderDomain := ""
if i := strings.LastIndex(s.mailFrom, "@"); i >= 0 {
senderDomain = s.mailFrom[i+1:]
}
if blocked, action, _ := s.server.database.MatchListRule(tenant.ID, s.mailFrom, senderDomain); blocked && action == db.ListActionBlock {
slog.Info("RCPT rejected — sender blocked by list rule", "from", s.mailFrom, "to", addr)
s.writeLine("550 5.7.1 Sender rejected")
return
}
var user *db.User
if u, err := s.server.database.LookupUserByEmail(addr); err == nil {
user = u
} else if !domain.AcceptAll {
slog.Debug("RCPT rejected — unknown user, domain not accept-all", "to", addr)
s.writeLine("550 5.1.1 User unknown")
return
}
s.rcptTo = append(s.rcptTo, addr)
s.recipientsValid = append(s.recipientsValid, recipientTarget{address: addr, user: user, tenantID: tenant.ID})
s.state = stateRcptTo
s.writeLine("250 2.1.5 OK")
}
func (s *session) handleData(ctx context.Context) {
if s.state != stateRcptTo || len(s.rcptTo) == 0 {
s.writeLine("503 5.5.1 RCPT TO required before DATA")
return
}
s.writeLine("354 Start mail input; end with <CRLF>.<CRLF>")
s.conn.SetReadDeadline(time.Now().Add(dataTimeout))
raw, err := s.readDotStuffed()
if err != nil {
s.writeLine("451 4.3.0 Error reading message data")
return
}
if int64(len(raw)) > s.server.maxMessageBytes {
s.writeLine(fmt.Sprintf("552 5.3.4 Message size exceeds maximum of %d bytes", s.server.maxMessageBytes))
s.reset()
return
}
subject := extractSubject(raw)
msgIDHdr := extractMessageID(raw)
deliveredCount := 0
for _, target := range s.recipientsValid {
msgID := uuid.NewString()
msg := &db.Message{
ID: msgID,
TenantID: target.tenantID,
FromAddress: s.mailFrom,
ToAddress: target.address,
Subject: subject,
MessageIDHdr: msgIDHdr,
SizeBytes: int64(len(raw)),
Verdict: db.VerdictClean,
SenderIP: senderIPString(s.senderIP),
}
// Insert the audit row immediately — message_checks rows inserted by
// the pipeline below FK-reference messages.id, so the parent row
// must exist first regardless of how long pipeline evaluation takes.
if err := s.server.database.InsertMessage(msg); err != nil {
slog.Error("failed to record message audit row", "err", err)
}
if target.user != nil {
deliverRaw := raw
msg.Verdict = db.VerdictClean
// Run the security pipeline only for true inbound mail from the
// internet (KindMTA) — mail submitted by an authenticated local
// user to another local user (KindSubmission/KindImplicitTLS) is
// treated as trusted internal mail and skips filtering, matching
// standard MTA practice.
if s.kind == KindMTA && s.server.pipeline != nil {
mc := &pipeline.MailContext{
SenderIP: s.senderIP,
SenderHost: s.senderHost,
MailFrom: s.mailFrom,
RcptTo: target.address,
RawMessage: raw,
}
s.server.pipeline.Run(ctx, mc)
msg.Verdict = mc.Verdict
msg.TotalScore = mc.TotalScore
for _, check := range mc.Checks {
mcRow := &db.MessageCheck{
ID: uuid.NewString(),
MessageID: msgID,
Stage: check.Stage,
Result: check.Result,
Score: check.Score,
Detail: check.Detail,
DurationMs: check.DurationMs,
}
if err := s.server.database.InsertMessageCheck(mcRow); err != nil {
slog.Error("failed to record pipeline check result", "err", err)
}
}
if msg.Verdict == db.VerdictFlagged {
deliverRaw = injectSpamHeaders(raw, mc.TotalScore, mc.Checks)
}
}
switch msg.Verdict {
case db.VerdictQuarantine, db.VerdictBlocked:
if err := s.quarantineMessage(msgID, raw, msg.Verdict); err != nil {
slog.Error("quarantine failed", "to", target.address, "err", err)
continue
}
slog.Info("message quarantined", "to", target.address, "verdict", msg.Verdict, "score", msg.TotalScore)
if err := s.server.database.UpdateMessageVerdict(msgID, msg.Verdict, msg.TotalScore, nil); err != nil {
slog.Error("failed to update message verdict", "err", err)
}
deliveredCount++ // "accepted" from the SMTP client's perspective — held, not bounced
default:
// Clean or flagged — check for an active Sieve script before
// delivering, so fileinto/discard rules apply to the same
// mail the security pipeline already cleared.
destFolder := "INBOX"
discard := false
if script, err := s.server.database.GetActiveSieveScript(target.user.ID); err == nil {
if result, applyErr := applySieve(script.ScriptText, deliverRaw); applyErr == nil {
switch result.Action {
case "fileinto":
destFolder = result.Folder
case "discard":
discard = true
}
} else {
slog.Warn("sieve script failed to apply, falling back to INBOX delivery", "user", target.user.Email, "err", applyErr)
}
}
now := time.Now().UTC()
if discard {
slog.Info("message discarded by sieve rule", "to", target.address)
if err := s.server.database.UpdateMessageVerdict(msgID, msg.Verdict, msg.TotalScore, &now); err != nil {
slog.Error("failed to update message verdict", "err", err)
}
deliveredCount++ // accepted from the SMTP client's perspective, then discarded per user's own rule
continue
}
if _, err := s.server.store.Deliver(target.user.ID, target.user.Email, destFolder, deliverRaw); err != nil {
slog.Error("local delivery failed", "to", target.address, "folder", destFolder, "err", err)
continue
}
msg.RelayedAt = &now
if err := s.server.database.UpdateMessageVerdict(msgID, msg.Verdict, msg.TotalScore, &now); err != nil {
slog.Error("failed to update message verdict", "err", err)
}
deliveredCount++
}
} else if s.isSubmissionKind() {
// Outbound to external address — stage the message and enqueue it
// for the background queue worker (internal/queue) to deliver.
_, queuePath, err := s.server.store.WriteQueueFile(raw)
if err != nil {
slog.Error("failed to stage outbound message", "to", target.address, "err", err)
continue
}
qEntry := &db.OutboundQueueEntry{
ID: uuid.NewString(),
UserID: s.authUser.ID,
FromAddress: s.mailFrom,
ToAddress: target.address,
EMLPath: queuePath,
NextAttemptAt: time.Now().UTC(),
}
if err := s.server.database.InsertOutboundQueueEntry(qEntry); err != nil {
slog.Error("failed to enqueue outbound message", "to", target.address, "err", err)
continue
}
now := time.Now().UTC()
s.server.database.UpdateMessageVerdict(msgID, db.VerdictClean, 0, &now)
slog.Info("outbound message queued", "to", target.address, "from", s.mailFrom)
deliveredCount++
} else {
slog.Warn("accept-all domain recipient has no mailbox yet — message accepted but not delivered", "to", target.address)
}
}
if deliveredCount == 0 {
s.writeLine("451 4.3.0 Temporary delivery failure")
s.reset()
return
}
s.writeLine("250 2.0.0 OK: message accepted")
s.reset()
}
// quarantineMessage stores the raw message encrypted in the quarantine area
// and creates the DB entry — called when the pipeline verdict is quarantine
// or blocked. The message is NOT delivered to the recipient's mailbox; it's
// held for admin/user review (release flow lands with the webmail/admin
// portal in a later phase; for now this establishes the storage half).
func (s *session) quarantineMessage(msgID string, raw []byte, verdict db.MessageVerdict) error {
path, err := s.server.store.WriteQuarantineFile(msgID, raw)
if err != nil {
return fmt.Errorf("write quarantine file: %w", err)
}
entry := &db.QuarantineEntry{
ID: uuid.NewString(),
MessageID: msgID,
EMLPath: path,
Status: db.QuarantineHeld,
Reason: fmt.Sprintf("verdict=%s", verdict),
ExpiresAt: time.Now().UTC().AddDate(0, 0, s.server.cfg.Storage.QuarantineDays),
}
if err := s.server.database.InsertQuarantineEntry(entry); err != nil {
return fmt.Errorf("insert quarantine entry: %w", err)
}
return nil
}
// injectSpamHeaders prepends X-Spam-* headers to a flagged (but still
// delivered) message so the recipient's mail client / webmail can surface
// the pipeline's findings without the message needing to be held.
// applySieve parses and executes a user's active Sieve script against a
// message's headers, returning the routing decision (fileinto/discard/keep).
// Headers are extracted fresh from raw rather than reusing any previously
// parsed structure, since this runs after the pipeline may have prepended
// X-Spam-* headers (injectSpamHeaders) — the script should see exactly what
// will be delivered, filters included.
func applySieve(scriptText string, raw []byte) (sieve.Result, error) {
parsed, err := sieve.Parse(scriptText)
if err != nil {
return sieve.Result{}, fmt.Errorf("parse: %w", err)
}
headers := extractHeaderMap(raw)
return sieve.Execute(parsed, headers), nil
}
// extractHeaderMap does a lightweight single-value-per-header extraction
// (last value wins for repeated headers) — sufficient for the header
// :contains / :is tests this Sieve subset supports.
func extractHeaderMap(raw []byte) map[string]string {
headers := map[string]string{}
text := string(raw)
headerEnd := strings.Index(text, "\r\n\r\n")
if headerEnd == -1 {
headerEnd = len(text)
}
for _, line := range strings.Split(text[:headerEnd], "\r\n") {
if line == "" {
continue
}
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && len(headers) > 0 {
continue // folded continuation — good enough for this subset, not appended
}
name, value, found := strings.Cut(line, ":")
if !found {
continue
}
headers[strings.TrimSpace(name)] = strings.TrimSpace(value)
}
return headers
}
func injectSpamHeaders(raw []byte, score float64, checks []pipeline.StageResult) []byte {
var failedStages []string
for _, c := range checks {
if c.Result == db.CheckFail || c.Result == db.CheckWarn {
failedStages = append(failedStages, c.Stage)
}
}
header := fmt.Sprintf("X-Spam-Score: %.1f\r\nX-Spam-Flag: YES\r\n", score)
if len(failedStages) > 0 {
header += fmt.Sprintf("X-Spam-Checks: %s\r\n", strings.Join(failedStages, ", "))
}
return append([]byte(header), raw...)
}
func (s *session) reset() {
s.mailFrom = ""
s.rcptTo = nil
s.recipientsValid = nil
if s.state != stateAuthenticated {
s.state = stateGreeted
} else {
s.state = stateAuthenticated
}
}
// ── I/O helpers ─────────────────────────────────────────────────────────────────
func (s *session) writeLine(line string) {
s.rw.WriteString(line)
s.rw.WriteString("\r\n")
s.rw.Flush()
}
func (s *session) readLine() (string, error) {
line, err := s.rw.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// readDotStuffed reads the DATA payload until the terminating "\r\n.\r\n",
// undoing dot-stuffing (a line starting with ".." becomes ".") per RFC 5321 §4.5.2.
func (s *session) readDotStuffed() ([]byte, error) {
var buf []byte
for {
line, err := s.rw.ReadString('\n')
if err != nil {
return nil, err
}
trimmed := strings.TrimRight(line, "\r\n")
if trimmed == "." {
return buf, nil
}
if strings.HasPrefix(trimmed, "..") {
trimmed = trimmed[1:]
}
buf = append(buf, []byte(trimmed)...)
buf = append(buf, '\r', '\n')
if int64(len(buf)) > s.server.maxMessageBytes+1024 {
return nil, fmt.Errorf("message exceeds max size during read")
}
}
}
// ── Parsing helpers ───────────────────────────────────────────────────────────
func splitVerb(line string) (verb, rest string) {
line = strings.TrimSpace(line)
i := strings.IndexAny(line, " :")
if i < 0 {
return line, ""
}
// Keep MAIL FROM: / RCPT TO: colon attached to rest for parseMailCmdArg.
if line[i] == ':' {
return line[:i], line[i:]
}
return line[:i], strings.TrimSpace(line[i+1:])
}
// parseMailCmdArg extracts the address from "FROM:<addr>" or "TO:<addr>" —
// tolerant of the colon being split into verb or rest depending on spacing.
func parseMailCmdArg(arg, prefix string) (string, bool) {
arg = strings.TrimSpace(arg)
upper := strings.ToUpper(arg)
prefixUpper := strings.ToUpper(prefix)
if strings.HasPrefix(upper, prefixUpper) {
arg = arg[len(prefix):]
} else if strings.HasPrefix(upper, ":") {
arg = arg[1:]
}
arg = strings.TrimSpace(arg)
// Strip angle brackets and any trailing ESMTP parameters (e.g. "SIZE=1234").
if i := strings.Index(arg, ">"); i >= 0 {
arg = arg[:i+1]
}
arg = strings.TrimPrefix(arg, "<")
arg = strings.TrimSuffix(arg, ">")
arg = strings.TrimSpace(arg)
if arg == "" {
return "", true // null sender (bounces) is valid: MAIL FROM:<>
}
if _, err := mail.ParseAddress(arg); err != nil {
return "", false
}
return arg, true
}
func senderIPString(ip net.IP) string {
if ip == nil {
return ""
}
return ip.String()
}
func extractSubject(raw []byte) string {
return extractHeader(raw, "Subject:")
}
func extractMessageID(raw []byte) string {
return extractHeader(raw, "Message-Id:")
}
func extractHeader(raw []byte, prefix string) string {
lines := strings.Split(string(raw), "\r\n")
for _, line := range lines {
if line == "" {
break // end of headers
}
if strings.HasPrefix(strings.ToLower(line), strings.ToLower(prefix)) {
return strings.TrimSpace(line[len(prefix):])
}
}
return ""
}
+227
View File
@@ -0,0 +1,227 @@
package tlsutil
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"sync"
"time"
"gomail/internal/acme"
"gomail/internal/crypto"
"gomail/internal/db"
)
// renewalMargin is how far before expiry a certificate is renewed.
const renewalMargin = 30 * 24 * time.Hour
// ACMEManager obtains and caches ACME certificates per domain, encrypted at
// rest (same HKDF-per-record scheme as everything else), and serves them
// via a SNI-aware tls.Config.GetCertificate callback so a single listener
// can present the right certificate for whichever domain a client connects
// to. A background loop renews any certificate within renewalMargin of
// expiry.
type ACMEManager struct {
database *db.DB
mk *crypto.MasterKey
directoryURL string
contactEmail string
responder *acme.ChallengeResponder
mu sync.RWMutex
cache map[string]*tls.Certificate
}
func NewACMEManager(database *db.DB, mk *crypto.MasterKey, directoryURL, contactEmail string, responder *acme.ChallengeResponder) *ACMEManager {
return &ACMEManager{
database: database, mk: mk, directoryURL: directoryURL, contactEmail: contactEmail,
responder: responder, cache: make(map[string]*tls.Certificate),
}
}
// TLSConfig returns a tls.Config whose GetCertificate looks up the right
// cert per SNI, obtaining one on first use if none is cached yet.
func (m *ACMEManager) TLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
return m.CertificateFor(hello.ServerName)
},
}
}
// CertificateFor returns a cached certificate for domain, obtaining one via
// ACME (and caching it, in memory and encrypted in the DB) if not already
// cached or if the cached one is expired/near expiry.
func (m *ACMEManager) CertificateFor(domain string) (*tls.Certificate, error) {
m.mu.RLock()
cached, ok := m.cache[domain]
m.mu.RUnlock()
if ok {
return cached, nil
}
if stored, err := m.loadFromDB(domain); err == nil {
m.mu.Lock()
m.cache[domain] = stored
m.mu.Unlock()
return stored, nil
}
cert, err := m.obtainAndStore(domain)
if err != nil {
return nil, err
}
return cert, nil
}
func (m *ACMEManager) loadFromDB(domain string) (*tls.Certificate, error) {
row, err := m.database.GetTLSCert(domain)
if err != nil {
return nil, err
}
if row.CertPEMEnc == nil || row.KeyPEMEnc == nil {
return nil, fmt.Errorf("no cert material stored for %s", domain)
}
if row.ExpiresAt != nil && time.Now().UTC().After(row.ExpiresAt.Add(-renewalMargin)) {
return nil, fmt.Errorf("stored cert for %s is expired or near expiry", domain)
}
certPEM, err := crypto.Decrypt(m.mk, row.ID, "tls-cert", row.CertPEMEnc)
if err != nil {
return nil, fmt.Errorf("decrypting cert: %w", err)
}
keyPEM, err := crypto.Decrypt(m.mk, row.ID, "tls-key", row.KeyPEMEnc)
if err != nil {
return nil, fmt.Errorf("decrypting key: %w", err)
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("parsing stored cert/key: %w", err)
}
return &cert, nil
}
func (m *ACMEManager) obtainAndStore(domain string) (*tls.Certificate, error) {
accountKey, err := m.loadOrCreateAccountKey(domain)
if err != nil {
return nil, fmt.Errorf("account key: %w", err)
}
slog.Info("obtaining ACME certificate", "domain", domain, "directory", m.directoryURL)
certPEM, keyPEM, err := acme.Obtain(m.directoryURL, m.contactEmail, []string{domain}, accountKey, m.responder)
if err != nil {
return nil, fmt.Errorf("ACME obtain for %s: %w", domain, err)
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("parsing obtained cert/key: %w", err)
}
var expiresAt *time.Time
if len(cert.Certificate) > 0 {
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
expiresAt = &leaf.NotAfter
}
}
existing, _ := m.database.GetTLSCert(domain)
recordID := domain
if existing != nil {
recordID = existing.ID
}
encCert, err := crypto.Encrypt(m.mk, recordID, "tls-cert", certPEM)
if err != nil {
return nil, fmt.Errorf("encrypting cert: %w", err)
}
encKey, err := crypto.Encrypt(m.mk, recordID, "tls-key", keyPEM)
if err != nil {
return nil, fmt.Errorf("encrypting key: %w", err)
}
if err := m.database.UpsertTLSCert(&db.TLSCert{
ID: recordID, Domain: domain, CertPEMEnc: encCert, KeyPEMEnc: encKey, ExpiresAt: expiresAt,
}); err != nil {
return nil, fmt.Errorf("storing cert: %w", err)
}
m.mu.Lock()
m.cache[domain] = &cert
m.mu.Unlock()
slog.Info("ACME certificate obtained and stored", "domain", domain, "expires_at", expiresAt)
return &cert, nil
}
func (m *ACMEManager) loadOrCreateAccountKey(domain string) (*acme.AccountKey, error) {
row, err := m.database.GetTLSCert(domain)
if err == nil && row.ACMEAccountKeyEnc != nil {
plain, decErr := crypto.Decrypt(m.mk, row.ID, "acme-account-key", row.ACMEAccountKeyEnc)
if decErr == nil {
if key, parseErr := acme.ParseAccountKeyPEM(plain); parseErr == nil {
return key, nil
}
}
}
key, err := acme.GenerateAccountKey()
if err != nil {
return nil, err
}
keyPEM, err := key.MarshalPEM()
if err != nil {
return nil, err
}
recordID := domain
if row != nil {
recordID = row.ID
}
encKey, err := crypto.Encrypt(m.mk, recordID, "acme-account-key", keyPEM)
if err != nil {
return nil, err
}
if err := m.database.SetACMEAccountKey(domain, encKey); err != nil {
return nil, err
}
return key, nil
}
// StartRenewalLoop runs a background check (default: daily) and renews any
// domain whose cached/stored certificate is within renewalMargin of expiry.
// domains is the full set this instance is responsible for — typically all
// active hosted domains plus the server's own hostname.
func (m *ACMEManager) StartRenewalLoop(ctx context.Context, domains []string, checkInterval time.Duration) {
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
checkAndRenew := func() {
for _, domain := range domains {
row, err := m.database.GetTLSCert(domain)
needsRenewal := err != nil || row.ExpiresAt == nil || time.Now().UTC().After(row.ExpiresAt.Add(-renewalMargin))
if !needsRenewal {
continue
}
slog.Info("renewing ACME certificate", "domain", domain)
m.mu.Lock()
delete(m.cache, domain) // force re-obtain, not a stale in-memory hit
m.mu.Unlock()
if _, err := m.obtainAndStore(domain); err != nil {
slog.Error("ACME renewal failed", "domain", domain, "err", err)
}
}
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkAndRenew()
}
}
}
+114
View File
@@ -0,0 +1,114 @@
// Package tlsutil provides certificate loading: LoadOrGenerate for the
// file/self-signed paths (this file), and ACMEManager (acme_manager.go) for
// real Let's Encrypt-style issuance via internal/acme. The self-signed
// generator here remains the fallback for tls.mode "off" or "file" without
// a cert on disk yet — genuinely necessary for local dev/testing, not a
// placeholder for a missing feature.
package tlsutil
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"log/slog"
"math/big"
"net"
"time"
)
// LoadOrGenerate returns a tls.Config for the given mode:
// - "file": load cert/key from disk paths
// - anything else ("acme" not yet implemented, "off"): generate a self-signed
// cert so STARTTLS/IMAPS/etc. still work during development. Logs a loud
// warning since this is never appropriate for production.
func LoadOrGenerate(mode, hostname, certFile, keyFile string, minVersion uint16) (*tls.Config, error) {
var cert tls.Certificate
var err error
switch mode {
case "file":
cert, err = tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("loading TLS cert/key: %w", err)
}
case "acme":
// Reaching here (rather than the real ACMEManager path in main.go)
// means mode=="acme" but no acme_domains were configured — a real
// ACME client exists (internal/acme, wired in main.go), it's just
// not usable without knowing which domain(s) to request a cert for.
slog.Warn("TLS mode is 'acme' but no acme_domains are configured — "+
"generating a SELF-SIGNED certificate instead. Set tls.acme_domains "+
"in config.yaml to enable real Let's Encrypt issuance.",
"hostname", hostname)
cert, err = generateSelfSigned(hostname)
if err != nil {
return nil, fmt.Errorf("generating self-signed cert: %w", err)
}
default:
slog.Warn("TLS mode is 'off' — generating a SELF-SIGNED certificate. "+
"This is fine for local testing but MUST NOT be used in production; "+
"set tls.mode to 'acme' (with acme_domains configured) or 'file'.",
"mode", mode, "hostname", hostname)
cert, err = generateSelfSigned(hostname)
if err != nil {
return nil, fmt.Errorf("generating self-signed cert: %w", err)
}
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: minVersion,
ServerName: hostname,
}, nil
}
// ParseMinVersion converts the config string ("TLS12"/"TLS13") to the
// crypto/tls constant.
func ParseMinVersion(s string) uint16 {
if s == "TLS13" {
return tls.VersionTLS13
}
return tls.VersionTLS12
}
func generateSelfSigned(hostname string) (tls.Certificate, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, err
}
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: hostname, Organization: []string{"GoMail (self-signed, dev only)"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IsCA: true,
BasicConstraintsValid: true,
}
if ip := net.ParseIP(hostname); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{hostname}
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, err
}
return tls.Certificate{
Certificate: [][]byte{derBytes},
PrivateKey: priv,
}, nil
}
+123
View File
@@ -0,0 +1,123 @@
// Package totp implements TOTP (RFC 6238, built on HOTP RFC 4226) —
// hand-rolled on stdlib crypto/hmac + crypto/sha1 + encoding/base32, no
// third-party OTP library. Correctness is checked against RFC 6238's own
// published test vectors (Appendix B) in the test suite, not just "it
// produces a 6-digit number."
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"fmt"
"math"
"net/url"
"strconv"
"strings"
"time"
)
const (
period = 30 // seconds per RFC 6238's recommended default
digits = 6
)
// GenerateSecret creates a new random 20-byte (160-bit) secret, base32
// encoded — the standard size real authenticator apps (Google Authenticator,
// Authy, etc.) expect.
func GenerateSecret() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generating TOTP secret: %w", err)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
}
// Generate computes the TOTP code for secret at the given time — exported
// primarily so the test suite can check RFC 6238's published vectors, which
// specify exact codes for exact timestamps.
func Generate(secret string, at time.Time) (string, error) {
key, err := decodeSecret(secret)
if err != nil {
return "", err
}
counter := uint64(at.Unix() / period)
return hotp(key, counter), nil
}
// Validate checks code against the current time step and, per common TOTP
// practice, the one step before and after (±30s) to tolerate minor clock
// drift between server and authenticator app.
func Validate(secret, code string) (bool, error) {
key, err := decodeSecret(secret)
if err != nil {
return false, err
}
code = strings.TrimSpace(code)
now := time.Now().UTC()
counter := uint64(now.Unix() / period)
for _, skew := range []int64{0, -1, 1} {
c := hotp(key, uint64(int64(counter)+skew))
if c == code {
return true, nil
}
}
return false, nil
}
// hotp implements RFC 4226 HOTP — the counter-based primitive TOTP wraps.
func hotp(key []byte, counter uint64) string {
msg := make([]byte, 8)
for i := 7; i >= 0; i-- {
msg[i] = byte(counter & 0xff)
counter >>= 8
}
mac := hmac.New(sha1.New, key)
mac.Write(msg)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
binCode := (uint32(sum[offset])&0x7f)<<24 |
(uint32(sum[offset+1])&0xff)<<16 |
(uint32(sum[offset+2])&0xff)<<8 |
(uint32(sum[offset+3]) & 0xff)
mod := uint32(math.Pow10(digits))
return fmt.Sprintf("%0*d", digits, binCode%mod)
}
func decodeSecret(secret string) ([]byte, error) {
secret = strings.ToUpper(strings.TrimSpace(secret))
secret = strings.ReplaceAll(secret, " ", "")
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
return nil, fmt.Errorf("decoding TOTP secret: %w", err)
}
return key, nil
}
// ProvisioningURI builds the otpauth:// URI real authenticator apps use to
// set up an account — either scanned as a QR code (QR rendering itself is
// deliberately not implemented here, see package doc note below) or
// manually entered, since every mainstream authenticator app supports
// typing in the secret directly as a fallback to scanning.
//
// Note: this package does not generate a QR code image. Real QR encoding
// (Reed-Solomon error correction, matrix placement) is a substantial
// sub-project of its own with little shared surface with TOTP itself —
// deferred rather than half-implemented. The webmail MFA setup page
// displays this URI as both a copyable string and (optionally, via a
// client-side QR library the frontend can add later) a scannable code.
func ProvisioningURI(secret, accountEmail, issuer string) string {
v := url.Values{}
v.Set("secret", secret)
v.Set("issuer", issuer)
v.Set("algorithm", "SHA1")
v.Set("digits", strconv.Itoa(digits))
v.Set("period", strconv.Itoa(period))
label := url.PathEscape(issuer) + ":" + url.PathEscape(accountEmail)
return fmt.Sprintf("otpauth://totp/%s?%s", label, v.Encode())
}
+148
View File
@@ -0,0 +1,148 @@
// Package vcard implements a minimal RFC 6350 vCard parser/builder — just
// the fields CardDAV needs to round-trip contacts: UID, FN, N, EMAIL, TEL,
// ORG, NOTE. Not a full vCard 4.0 implementation (no PHOTO, no groups, no
// extended params) — enough for real mail/contacts clients to store and
// retrieve a usable contact, with more fields added as client compatibility
// testing surfaces the need.
package vcard
import (
"fmt"
"strings"
)
type Card struct {
UID string
FN string // formatted name
N string // structured name: Family;Given;Middle;Prefix;Suffix
Email []string
Tel []string
Org string
Note string
}
// Parse reads a single vCard (BEGIN:VCARD...END:VCARD) into a Card.
func Parse(data string) (*Card, error) {
lines := unfold(data)
c := &Card{}
inCard := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
upper := strings.ToUpper(line)
switch {
case upper == "BEGIN:VCARD":
inCard = true
continue
case upper == "END:VCARD":
inCard = false
continue
}
if !inCard {
continue
}
name, value, found := splitProperty(line)
if !found {
continue
}
switch strings.ToUpper(name) {
case "UID":
c.UID = value
case "FN":
c.FN = value
case "N":
c.N = value
case "EMAIL":
c.Email = append(c.Email, value)
case "TEL":
c.Tel = append(c.Tel, value)
case "ORG":
c.Org = value
case "NOTE":
c.Note = unescape(value)
}
}
if c.UID == "" {
return nil, fmt.Errorf("vcard missing required UID property")
}
return c, nil
}
// Build renders a Card back into vCard 4.0 text, CRLF line endings per spec.
func (c *Card) Build() string {
var b strings.Builder
b.WriteString("BEGIN:VCARD\r\n")
b.WriteString("VERSION:4.0\r\n")
b.WriteString("UID:" + c.UID + "\r\n")
if c.FN != "" {
b.WriteString("FN:" + escape(c.FN) + "\r\n")
}
if c.N != "" {
b.WriteString("N:" + c.N + "\r\n")
}
for _, e := range c.Email {
b.WriteString("EMAIL:" + e + "\r\n")
}
for _, t := range c.Tel {
b.WriteString("TEL:" + t + "\r\n")
}
if c.Org != "" {
b.WriteString("ORG:" + escape(c.Org) + "\r\n")
}
if c.Note != "" {
b.WriteString("NOTE:" + escape(c.Note) + "\r\n")
}
b.WriteString("END:VCARD\r\n")
return b.String()
}
// splitProperty splits "NAME;PARAM=x:value" into (name, value) — parameters
// are discarded in this minimal pass (deferred: TYPE=work/home distinction).
func splitProperty(line string) (name, value string, found bool) {
colonIdx := strings.Index(line, ":")
if colonIdx == -1 {
return "", "", false
}
namePart := line[:colonIdx]
value = line[colonIdx+1:]
if semiIdx := strings.Index(namePart, ";"); semiIdx != -1 {
namePart = namePart[:semiIdx]
}
return namePart, value, true
}
// unfold reverses RFC 6350 §3.2 line folding (a line starting with a single
// space or tab is a continuation of the previous line).
func unfold(data string) []string {
raw := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n")
var out []string
for _, line := range raw {
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 {
out[len(out)-1] += line[1:]
} else {
out = append(out, line)
}
}
return out
}
func escape(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, "\n", "\\n")
return s
}
func unescape(s string) string {
s = strings.ReplaceAll(s, "\\n", "\n")
s = strings.ReplaceAll(s, "\\,", ",")
s = strings.ReplaceAll(s, "\\;", ";")
s = strings.ReplaceAll(s, "\\\\", "\\")
return s
}
+29
View File
@@ -0,0 +1,29 @@
package vcard
import "testing"
func FuzzParse(f *testing.F) {
f.Add("BEGIN:VCARD\r\nVERSION:4.0\r\nUID:test-1\r\nFN:Test Person\r\nEND:VCARD\r\n")
f.Add("BEGIN:VCARD\nUID:no-crlf\nEND:VCARD\n")
f.Add("BEGIN:VCARD\r\nUID:folded\r\nNOTE:line one\r\n continued\r\nEND:VCARD\r\n")
f.Add("BEGIN:VCARD\r\nUID:escaped\r\nNOTE:a\\,b\\;c\\\\d\\ne\r\nEND:VCARD\r\n")
f.Add("")
f.Add("BEGIN:VCARD\r\nEND:VCARD\r\n")
f.Add("not a vcard at all")
f.Add("BEGIN:VCARD\r\n:\r\nEND:VCARD\r\n")
f.Add("BEGIN:VCARD\r\nUID\r\nEND:VCARD\r\n")
f.Add("BEGIN:VCARD\r\n;;;:;;;\r\nUID:x\r\nEND:VCARD\r\n")
f.Fuzz(func(t *testing.T, data string) {
// The only contract checked here: Parse must never panic on any
// input, malformed or not — a returned error is fine, a crash is
// not, since this parser runs on untrusted client-supplied CardDAV
// PUT bodies.
defer func() {
if r := recover(); r != nil {
t.Fatalf("Parse panicked on input %q: %v", data, r)
}
}()
Parse(data)
})
}
+958
View File
@@ -0,0 +1,958 @@
// Package webmail implements the REST API and embedded SPA for GoMail's own
// webmail client. The API wraps internal/accounts.GoMailProvider for message
// operations — direct local access, no JMAP dependency — so this phase isn't
// blocked on Phase 9's JMAP server. When JMAP lands, only this package's
// internals need to change; the REST contract (and therefore the frontend)
// stays the same.
package webmail
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gomail/internal/accounts"
"gomail/internal/auth"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/mailstore"
"gomail/internal/oauth2"
"gomail/internal/totp"
"gomail/internal/webtoken"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const sessionTTL = 24 * time.Hour
type Handler struct {
database *db.DB
store *mailstore.Store
mk *crypto.MasterKey
jwtSecret string
oauthConfigs map[string]*oauth2.Config // keyed by "google" / "microsoft", nil entries if not configured
oauthStateMu sync.Mutex
oauthState map[string]oauthStateEntry // CSRF state -> pending link request
}
type oauthStateEntry struct {
UserID string
Provider string
ExpiresAt time.Time
}
func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret string, oauthConfigs map[string]*oauth2.Config) *Handler {
return &Handler{
database: database, store: store, mk: mk, jwtSecret: jwtSecret,
oauthConfigs: oauthConfigs,
oauthState: make(map[string]oauthStateEntry),
}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/auth/login", h.login)
mux.HandleFunc("/api/auth/mfa-verify", h.mfaVerify)
mux.HandleFunc("/api/auth/forgot-password", h.forgotPassword)
mux.HandleFunc("/api/auth/reset-password", h.resetPassword)
mux.HandleFunc("/api/me", h.withAuth(h.getMe))
mux.HandleFunc("/api/me/mfa/setup", h.withAuth(h.mfaSetup))
mux.HandleFunc("/api/me/mfa/confirm", h.withAuth(h.mfaConfirm))
mux.HandleFunc("/api/me/mfa/disable", h.withAuth(h.mfaDisable))
mux.HandleFunc("/api/me/recovery-email", h.withAuth(h.setRecoveryEmail))
mux.HandleFunc("/api/me/app-passwords", h.withAuth(h.appPasswords))
mux.HandleFunc("/api/me/app-passwords/", h.withAuth(h.appPasswordByID))
mux.HandleFunc("/api/folders", h.withAuth(h.listFolders))
mux.HandleFunc("/api/folders/", h.withAuth(h.listMessages))
mux.HandleFunc("/api/messages", h.withAuth(h.sendOrListMessages))
mux.HandleFunc("/api/messages/", h.withAuth(h.messageByID))
mux.HandleFunc("/api/quarantine", h.withAuth(h.listQuarantine))
mux.HandleFunc("/api/quarantine/", h.withAuth(h.releaseQuarantine))
mux.HandleFunc("/api/events", h.withAuth(h.sseEvents))
mux.HandleFunc("/api/accounts", h.withAuth(h.listAccounts))
mux.HandleFunc("/api/accounts/oauth/", h.oauthDispatch) // start needs auth (checked inline), callback doesn't (browser redirect)
mux.HandleFunc("/api/accounts/", h.withAuth(h.deleteAccount))
}
// ── JSON helpers ──────────────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// ── Auth ──────────────────────────────────────────────────────────────────────
// titleCase upper-cases s's first byte — used only for the ASCII provider
// names ("google", "microsoft") in display strings; strings.Title is
// deprecated and its Unicode word-boundary handling is unneeded here.
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ Email, Password string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
user, ok := auth.Authenticate(h.database, req.Email, req.Password, auth.ScopeIMAP)
if !ok {
slog.Info("webmail login failed", "email", req.Email)
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
if user.MFAEnabled {
// Password alone is not enough — issue a short-lived, narrowly-scoped
// pre-auth token instead of a real session. It can only be redeemed
// at /api/auth/mfa-verify, and only with a correct TOTP or backup code.
mfaToken, err := webtoken.IssueWithPurpose(h.jwtSecret, user.ID, user.TenantID, string(user.Role), "mfa_pending", 5*time.Minute)
if err != nil {
writeErr(w, http.StatusInternalServerError, "token generation failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"mfa_required": true, "mfa_token": mfaToken})
return
}
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
if err != nil {
writeErr(w, http.StatusInternalServerError, "token generation failed")
return
}
h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"token": token,
"user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName},
})
}
// mfaVerify completes login for an MFA-enabled account — redeems the
// pre-auth token from login() plus a valid TOTP or backup code for a real
// session token.
func (h *Handler) mfaVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ MFAToken, Code string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken)
if err != nil || claims.Purpose != "mfa_pending" {
writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session")
return
}
user, err := h.database.GetUser(claims.Subject)
if err != nil || !user.Active {
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
return
}
verified := false
if user.TOTPSecretEnc != nil {
plain, decErr := crypto.Decrypt(h.mk, user.ID, "totp-secret", user.TOTPSecretEnc)
if decErr == nil {
if ok, _ := totp.Validate(string(plain), req.Code); ok {
verified = true
}
}
}
if !verified {
// Fall back to a backup code — hashed the same way app passwords are.
hash := sha256Hex(req.Code)
if used, _ := h.database.ConsumeBackupCode(user.ID, hash); used {
verified = true
}
}
if !verified {
writeErr(w, http.StatusUnauthorized, "invalid code")
return
}
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
if err != nil {
writeErr(w, http.StatusInternalServerError, "token generation failed")
return
}
h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID)
writeJSON(w, http.StatusOK, map[string]any{
"token": token,
"user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName},
})
}
func (h *Handler) withAuth(next func(http.ResponseWriter, *http.Request, *db.User)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenStr := ""
if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
} else if cookie, err := r.Cookie("gomail_token"); err == nil {
tokenStr = cookie.Value
}
if tokenStr == "" {
writeErr(w, http.StatusUnauthorized, "missing token")
return
}
claims, err := webtoken.Verify(h.jwtSecret, tokenStr)
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
return
}
if claims.Purpose != "" {
// A purpose-scoped token (mfa_pending, password_reset) is not a
// session — accepting it here would let it bypass whatever the
// purpose was gating (e.g. MFA).
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
return
}
// claims.Subject is the user's ID (set at Issue time in login), not
// an email — look up directly by ID.
row := h.database.QueryRow(`SELECT id, tenant_id, domain_id, email, display_name, role, active FROM users WHERE id = ?`, claims.Subject)
var user db.User
if err := row.Scan(&user.ID, &user.TenantID, &user.DomainID, &user.Email, &user.DisplayName, &user.Role, &user.Active); err != nil {
writeErr(w, http.StatusUnauthorized, "user not found")
return
}
if !user.Active {
writeErr(w, http.StatusForbidden, "account disabled")
return
}
next(w, r, &user)
}
}
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request, user *db.User) {
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID, "email": user.Email, "display_name": user.DisplayName, "role": user.Role,
})
}
// ── Folders & messages ──────────────────────────────────────────────────────────
func (h *Handler) provider(user *db.User) *accounts.GoMailProvider {
return accounts.NewGoMailProvider(h.database, h.store, user)
}
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request, user *db.User) {
folders, err := h.provider(user).ListFolders(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
// listMessages handles GET /api/folders/{folderID}/messages
func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request, user *db.User) {
path := strings.TrimPrefix(r.URL.Path, "/api/folders/")
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 || parts[1] != "messages" {
http.NotFound(w, r)
return
}
folderID := parts[0]
opts := accounts.ListOpts{}
if l := r.URL.Query().Get("limit"); l != "" {
opts.Limit, _ = strconv.Atoi(l)
}
if o := r.URL.Query().Get("offset"); o != "" {
opts.Offset, _ = strconv.Atoi(o)
}
headers, err := h.provider(user).ListMessages(r.Context(), folderID, opts)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, headers)
}
func (h *Handler) sendOrListMessages(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct {
To []string `json:"to"`
CC []string `json:"cc"`
Subject string `json:"subject"`
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.To) == 0 {
writeErr(w, http.StatusBadRequest, "at least one recipient required")
return
}
msg := &accounts.OutgoingMessage{From: user.Email, To: req.To, CC: req.CC, Subject: req.Subject, Body: req.Body}
if err := h.provider(user).SendMessage(r.Context(), msg); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "sent"})
}
// messageByID handles GET/PUT(flags)/DELETE/move on /api/messages/{folderID}/{messageID}[/flags|/move]
func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.User) {
path := strings.TrimPrefix(r.URL.Path, "/api/messages/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
http.NotFound(w, r)
return
}
folderID, messageID := parts[0], parts[1]
action := ""
if len(parts) >= 3 {
action = parts[2]
}
p := h.provider(user)
switch {
case r.Method == http.MethodGet && action == "":
full, err := p.GetMessage(r.Context(), folderID, messageID)
if err != nil {
writeErr(w, http.StatusNotFound, "message not found")
return
}
writeJSON(w, http.StatusOK, full)
case r.Method == http.MethodPut && action == "flags":
var req struct{ Flags []string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
if err := p.SetFlags(r.Context(), folderID, messageID, req.Flags); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "updated"})
case r.Method == http.MethodPost && action == "move":
var req struct{ DestFolder string `json:"dest_folder"` }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid body")
return
}
if err := p.Move(r.Context(), folderID, messageID, req.DestFolder); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "moved"})
case r.Method == http.MethodDelete && action == "":
if err := p.Delete(r.Context(), folderID, messageID); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// ── Quarantine ────────────────────────────────────────────────────────────────
func (h *Handler) listQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
entries, err := h.database.QuarantineEntriesForUser(user.Email, time.Now().AddDate(0, 0, -30))
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, entries)
}
func (h *Handler) releaseQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/quarantine/"), "/release")
entry, err := h.database.GetQuarantineEntry(id)
if err != nil {
writeErr(w, http.StatusNotFound, "quarantine entry not found")
return
}
var toAddr string
if err := h.database.QueryRow(`SELECT to_address FROM messages WHERE id = ?`, entry.MessageID).Scan(&toAddr); err != nil {
writeErr(w, http.StatusNotFound, "underlying message not found")
return
}
if toAddr != user.Email {
writeErr(w, http.StatusForbidden, "not your message")
return
}
raw, err := h.store.ReadQuarantineFile(entry.MessageID, entry.EMLPath)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to read quarantined message")
return
}
if _, err := h.store.Deliver(user.ID, user.Email, "INBOX", raw); err != nil {
writeErr(w, http.StatusInternalServerError, "failed to deliver released message")
return
}
if err := h.database.ReleaseQuarantineEntry(id, user.Email); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "released"})
}
// ── SSE ───────────────────────────────────────────────────────────────────────
// sseEvents streams a countUpdate event whenever the INBOX message count
// changes, polling every few seconds — a real push mechanism (fsnotify-style
// instant delivery) is a natural follow-up once IMAP IDLE's polling loop is
// generalized; this establishes the wire contract webmail's UI codes against
// today.
func (h *Handler) sseEvents(w http.ResponseWriter, r *http.Request, user *db.User) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ctx := r.Context()
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
lastCount := -1
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
entries, err := h.database.ListMailboxEntries(user.ID, "INBOX")
if err != nil {
continue
}
if len(entries) != lastCount {
lastCount = len(entries)
fmt.Fprintf(w, "event: countUpdate\ndata: {\"mailbox\":\"INBOX\",\"total\":%d}\n\n", len(entries))
flusher.Flush()
}
}
}
}
// ── Linked accounts ──────────────────────────────────────────────────────────
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request, user *db.User) {
accts, err := h.database.ListLinkedAccounts(user.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
// Never expose CredentialEnc — even encrypted, there's no reason to send
// it to the client at all.
type safeAccount struct {
ID string `json:"id"`
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
EmailAddress string `json:"email_address"`
LastSyncAt string `json:"last_sync_at,omitempty"`
}
out := make([]safeAccount, 0, len(accts))
for _, a := range accts {
sa := safeAccount{ID: a.ID, Provider: string(a.Provider), DisplayName: a.DisplayName, EmailAddress: a.EmailAddress}
if a.LastSyncAt != nil {
sa.LastSyncAt = a.LastSyncAt.Format(time.RFC3339)
}
out = append(out, sa)
}
writeJSON(w, http.StatusOK, out)
}
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/accounts/")
if id == "" || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
account, err := h.database.GetLinkedAccount(id)
if err != nil || account.UserID != user.ID {
writeErr(w, http.StatusNotFound, "account not found")
return
}
if err := h.database.DeactivateLinkedAccount(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "unlinked"})
}
// oauthDispatch routes /api/accounts/oauth/{provider}/start and .../callback.
// start requires an authenticated session (checked inline, not via withAuth,
// since callback intentionally does NOT require one — it's a plain browser
// redirect from the provider with no Authorization header available).
func (h *Handler) oauthDispatch(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/accounts/oauth/")
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 {
http.NotFound(w, r)
return
}
provider, action := parts[0], parts[1]
switch action {
case "start":
h.withAuth(func(w http.ResponseWriter, r *http.Request, user *db.User) {
h.oauthStart(w, r, user, provider)
})(w, r)
case "callback":
h.oauthCallback(w, r, provider)
default:
http.NotFound(w, r)
}
}
func (h *Handler) oauthStart(w http.ResponseWriter, r *http.Request, user *db.User, provider string) {
cfg, ok := h.oauthConfigs[provider]
if !ok || cfg == nil {
writeErr(w, http.StatusServiceUnavailable, fmt.Sprintf("%s OAuth is not configured on this server", provider))
return
}
state, err := randomState()
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to generate state")
return
}
h.oauthStateMu.Lock()
h.pruneExpiredState()
h.oauthState[state] = oauthStateEntry{UserID: user.ID, Provider: provider, ExpiresAt: time.Now().UTC().Add(10 * time.Minute)}
h.oauthStateMu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"auth_url": cfg.BuildAuthURL(state)})
}
func (h *Handler) oauthCallback(w http.ResponseWriter, r *http.Request, provider string) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
writeErr(w, http.StatusBadRequest, "missing code or state")
return
}
h.oauthStateMu.Lock()
entry, ok := h.oauthState[state]
if ok {
delete(h.oauthState, state) // one-time use
}
h.oauthStateMu.Unlock()
if !ok {
writeErr(w, http.StatusBadRequest, "invalid or expired state (possible CSRF attempt)")
return
}
if entry.Provider != provider {
writeErr(w, http.StatusBadRequest, "state/provider mismatch")
return
}
if time.Now().UTC().After(entry.ExpiresAt) {
writeErr(w, http.StatusBadRequest, "state expired, please try linking again")
return
}
cfg, ok := h.oauthConfigs[provider]
if !ok || cfg == nil {
writeErr(w, http.StatusServiceUnavailable, "provider not configured")
return
}
token, err := cfg.ExchangeCode(r.Context(), code)
if err != nil {
slog.Error("oauth2 code exchange failed", "provider", provider, "err", err)
writeErr(w, http.StatusBadGateway, "failed to exchange authorization code")
return
}
dbProvider := db.ProviderGmail
if provider == "microsoft" {
dbProvider = db.ProviderM365
}
// Note: a real implementation would call the provider's userinfo/profile
// endpoint here to learn the account's actual email address rather than
// require it as a query param — deferred; for now the display name is
// generic and the operator/user can rename it, matching the minimum
// needed to prove the OAuth2 flow itself is correct end-to-end.
email := r.URL.Query().Get("email")
if email == "" {
email = provider + "-account"
}
account, err := accounts.LinkOAuth2Account(h.database, h.mk, entry.UserID, titleCase(provider)+" Account", email, dbProvider, token)
if err != nil {
slog.Error("failed to store linked OAuth2 account", "err", err)
writeErr(w, http.StatusInternalServerError, "failed to link account")
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "linked", "account_id": account.ID})
}
func (h *Handler) pruneExpiredState() {
now := time.Now().UTC()
for k, v := range h.oauthState {
if now.After(v.ExpiresAt) {
delete(h.oauthState, k)
}
}
}
func randomState() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// ── MFA setup/confirm/disable ────────────────────────────────────────────────
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(strings.ToUpper(s))))
return hex.EncodeToString(sum[:])
}
// mfaSetup generates a new TOTP secret and stores it encrypted but NOT yet
// enabled — the user must confirm one valid code (mfaConfirm) before MFA
// actually takes effect, so an abandoned setup never locks anyone out.
func (h *Handler) mfaSetup(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
secret, err := totp.GenerateSecret()
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to generate secret")
return
}
encSecret, err := crypto.Encrypt(h.mk, user.ID, "totp-secret", []byte(secret))
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to encrypt secret")
return
}
if err := h.database.SetPendingTOTPSecret(user.ID, encSecret); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
uri := totp.ProvisioningURI(secret, user.Email, "GoMail")
writeJSON(w, http.StatusOK, map[string]string{"secret": secret, "provisioning_uri": uri})
}
// mfaConfirm verifies one code against the pending secret and, on success,
// enables MFA and generates backup codes (shown to the user exactly once).
func (h *Handler) mfaConfirm(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ Code string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
fresh, err := h.database.GetUser(user.ID)
if err != nil || fresh.TOTPSecretEnc == nil {
writeErr(w, http.StatusBadRequest, "no pending MFA setup — call /api/me/mfa/setup first")
return
}
plain, err := crypto.Decrypt(h.mk, user.ID, "totp-secret", fresh.TOTPSecretEnc)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to decrypt pending secret")
return
}
ok, err := totp.Validate(string(plain), req.Code)
if err != nil || !ok {
writeErr(w, http.StatusBadRequest, "invalid code")
return
}
backupCodes := make([]string, 8)
hashes := make([]string, 8)
for i := range backupCodes {
raw := make([]byte, 5)
rand.Read(raw)
code := strings.ToUpper(hex.EncodeToString(raw)) // 10 hex chars, easy to type
backupCodes[i] = code
hashes[i] = sha256Hex(code)
}
if err := h.database.ReplaceBackupCodes(user.ID, hashes); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := h.database.SetMFAEnabled(user.ID, true); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"message": "MFA enabled", "backup_codes": backupCodes})
}
func (h *Handler) mfaDisable(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ Password string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
// Require the password again — disabling MFA is high-stakes enough that
// a hijacked-but-still-logged-in session shouldn't be able to do it
// with just the session token.
if _, ok := auth.Authenticate(h.database, user.Email, req.Password, auth.ScopeIMAP); !ok {
writeErr(w, http.StatusUnauthorized, "incorrect password")
return
}
if err := h.database.ClearTOTPSecret(user.ID); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "MFA disabled"})
}
// ── App passwords ─────────────────────────────────────────────────────────────
func (h *Handler) appPasswords(w http.ResponseWriter, r *http.Request, user *db.User) {
switch r.Method {
case http.MethodGet:
rows, err := h.database.Query(`SELECT id, label, scopes, last_used_at, expires_at, created_at FROM app_passwords WHERE user_id = ? ORDER BY created_at DESC`, user.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
type entry struct {
ID, Label, Scopes string
LastUsedAt, ExpiresAt *time.Time
CreatedAt time.Time
}
var out []entry
for rows.Next() {
var e entry
if err := rows.Scan(&e.ID, &e.Label, &e.Scopes, &e.LastUsedAt, &e.ExpiresAt, &e.CreatedAt); err != nil {
continue
}
out = append(out, e)
}
writeJSON(w, http.StatusOK, out)
case http.MethodPost:
var req struct {
Label string
Scopes string
ExpiresIn string // e.g. "30d", "" = never
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Label == "" {
writeErr(w, http.StatusBadRequest, "label is required")
return
}
if req.Scopes == "" {
req.Scopes = "smtp,imap"
}
raw := make([]byte, 24)
rand.Read(raw)
token := strings.ToUpper(hex.EncodeToString(raw))
hash, err := bcrypt.GenerateFromPassword([]byte(token), 12)
if err != nil {
writeErr(w, http.StatusInternalServerError, "hashing failed")
return
}
var expiresAt *time.Time
if req.ExpiresIn != "" {
d, err := parseDuration(req.ExpiresIn)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid expires_in format (use e.g. '30d', '90d')")
return
}
t := time.Now().UTC().Add(d)
expiresAt = &t
}
id := uuid.NewString()
_, err = h.database.Exec(`INSERT INTO app_passwords (id, user_id, label, password_hash, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)`,
id, user.ID, req.Label, string(hash), req.Scopes, expiresAt)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": id, "token": token}) // token shown exactly once
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) appPasswordByID(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/me/app-passwords/")
res, err := h.database.Exec(`DELETE FROM app_passwords WHERE id = ? AND user_id = ?`, id, user.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
writeErr(w, http.StatusNotFound, "app password not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "revoked"})
}
func parseDuration(s string) (time.Duration, error) {
if strings.HasSuffix(s, "d") {
var days int
if _, err := fmt.Sscanf(s, "%dd", &days); err != nil {
return 0, err
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}
// ── Password reset (recovery-email based) ────────────────────────────────────
// forgotPassword always returns 200 regardless of whether the email
// matches an account or that account has a recovery email configured —
// leaking account existence via response differences is exactly what this
// guards against.
func (h *Handler) forgotPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ Email string }
json.NewDecoder(r.Body).Decode(&req)
user, err := h.database.LookupUserByEmail(req.Email)
if err == nil && user.RecoveryEmail != "" {
fingerprint := webtoken.Fingerprint(user.PasswordHash)
resetToken, tokErr := webtoken.IssueResetToken(h.jwtSecret, user.ID, user.TenantID, string(user.Role), fingerprint, 1*time.Hour)
if tokErr == nil {
body := fmt.Sprintf("A password reset was requested for your GoMail account (%s).\r\n\r\n"+
"Reset token (valid 1 hour): %s\r\n\r\n"+
"If you didn't request this, you can safely ignore this message.\r\n", user.Email, resetToken)
raw := []byte(fmt.Sprintf("From: noreply@gomail\r\nTo: %s\r\nSubject: GoMail password reset\r\n\r\n%s", user.RecoveryEmail, body))
if _, queuePath, qErr := h.store.WriteQueueFile(raw); qErr == nil {
h.database.InsertOutboundQueueEntry(&db.OutboundQueueEntry{
ID: uuid.NewString(), UserID: user.ID, FromAddress: "noreply@" + strings.SplitN(user.Email, "@", 2)[1],
ToAddress: user.RecoveryEmail, EMLPath: queuePath, NextAttemptAt: time.Now().UTC(),
})
}
}
}
writeJSON(w, http.StatusOK, map[string]string{"message": "if an account with recovery email configured exists, a reset link has been sent"})
}
func (h *Handler) resetPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ Token, NewPassword string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.NewPassword) < 8 {
writeErr(w, http.StatusBadRequest, "new_password must be at least 8 characters")
return
}
claims, err := webtoken.Verify(h.jwtSecret, req.Token)
if err != nil || claims.Purpose != "password_reset" {
writeErr(w, http.StatusBadRequest, "invalid or expired reset token")
return
}
current, err := h.database.GetUser(claims.Subject)
if err != nil || !webtoken.FingerprintMatches(claims, current.PasswordHash) {
// Either the user no longer exists, or the password has already
// been changed since this token was issued (including via a prior
// use of this same token) — reject either way, single-use enforced.
writeErr(w, http.StatusBadRequest, "invalid or expired reset token")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
if err != nil {
writeErr(w, http.StatusInternalServerError, "hashing failed")
return
}
if err := h.database.SetUserPassword(claims.Subject, string(hash)); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "password reset successful"})
}
func (h *Handler) setRecoveryEmail(w http.ResponseWriter, r *http.Request, user *db.User) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ RecoveryEmail string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.database.SetRecoveryEmail(user.ID, req.RecoveryEmail); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "recovery email updated"})
}
+6
View File
@@ -0,0 +1,6 @@
package webmail
import "embed"
//go:embed static/index.html
var StaticFS embed.FS
+203
View File
@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoMail</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0}
.sidebar{width:220px;background:#1e293b;border-right:1px solid #334155;min-height:100vh;position:fixed;top:0;left:0;bottom:0}
.main{margin-left:220px;display:flex;min-height:100vh}
.msg-list{width:340px;border-right:1px solid #334155;overflow-y:auto}
.msg-view{flex:1;padding:24px;overflow-y:auto}
.nav-item{padding:9px 16px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:8px;margin:2px 8px}
.nav-item:hover{background:#334155}
.nav-item.active{background:#7c3aed22;color:#a78bfa}
.msg-row{padding:12px 16px;border-bottom:1px solid #1e293b;cursor:pointer;font-size:13px}
.msg-row:hover{background:#1e293b80}
.msg-row.unread{font-weight:600}
.btn{padding:7px 14px;border-radius:7px;font-size:13px;font-weight:500;cursor:pointer;border:none}
.btn-primary{background:#7c3aed;color:#fff}
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}
.inp{background:#0f172a;border:1px solid #334155;border-radius:7px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%}
.modal-bg{position:fixed;inset:0;background:#00000088;z-index:50;display:flex;align-items:center;justify-content:center}
.modal{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:24px;width:560px;max-width:95vw}
.badge{padding:2px 8px;border-radius:10px;font-size:11px}
</style>
</head>
<body>
<div id="login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
<div style="background:#1e293b;border:1px solid #334155;border-radius:14px;padding:28px;width:320px">
<div style="text-align:center;margin-bottom:20px"><div style="font-size:2.5rem">📧</div>
<h1 style="font-weight:700;color:#fff">GoMail</h1></div>
<input id="le" class="inp" placeholder="you@example.com" style="margin-bottom:10px">
<input id="lp" type="password" class="inp" placeholder="Password" style="margin-bottom:10px" onkeydown="if(event.key==='Enter')login()">
<button onclick="login()" class="btn btn-primary" style="width:100%">Sign in</button>
<p id="lerr" style="display:none;color:#f87171;font-size:12px;text-align:center;margin-top:10px"></p>
</div>
</div>
<div id="app" style="display:none">
<aside class="sidebar">
<div style="padding:16px;border-bottom:1px solid #334155;font-weight:700;color:#fff">📧 GoMail</div>
<div style="padding:12px 8px">
<button onclick="openCompose()" class="btn btn-primary" style="width:100%;margin-bottom:12px">✎ Compose</button>
<div id="folder-list"></div>
<div class="nav-item" onclick="showQuarantine()" id="nav-quarantine" style="margin-top:8px">🔒 Quarantine</div>
</div>
<div style="position:absolute;bottom:0;padding:12px;border-top:1px solid #334155;width:100%;box-sizing:border-box">
<span id="me-email" style="font-size:12px;color:#64748b"></span>
<button onclick="logout()" style="float:right;font-size:11px;color:#475569;background:none;border:none;cursor:pointer">Logout</button>
</div>
</aside>
<main class="main">
<div id="view-mail" style="display:flex;flex:1">
<div class="msg-list" id="msg-list"></div>
<div class="msg-view" id="msg-view"><div style="color:#475569;text-align:center;margin-top:60px">Select a message</div></div>
</div>
<div id="view-quarantine" style="display:none;flex:1;padding:24px">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Quarantine</h2>
<div id="quarantine-list"></div>
</div>
</main>
</div>
<div id="compose-modal" class="modal-bg" style="display:none">
<div class="modal">
<h3 style="color:#fff;font-weight:700;margin-bottom:16px">New Message</h3>
<input id="c-to" class="inp" placeholder="To" style="margin-bottom:8px">
<input id="c-subject" class="inp" placeholder="Subject" style="margin-bottom:8px">
<textarea id="c-body" class="inp" rows="8" placeholder="Message..." style="margin-bottom:12px"></textarea>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeCompose()" class="btn btn-ghost">Cancel</button>
<button onclick="sendMessage()" class="btn btn-primary">Send</button>
</div>
</div>
</div>
<script>
const API='/api';
let token=localStorage.getItem('gomail_token')||'';
let currentFolder='INBOX';
async function api(path,opts={}){
const r=await fetch(API+path,{...opts,headers:{'Content-Type':'application/json','Authorization':'Bearer '+token,...(opts.headers||{})}});
if(r.status===401){showLogin();return null;}
return r.ok?r.json():Promise.reject(await r.json());
}
async function login(){
const email=document.getElementById('le').value,pwd=document.getElementById('lp').value;
try{
const d=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({Email:email,Password:pwd})}).then(r=>r.json());
if(d.error)throw new Error(d.error);
token=d.token;localStorage.setItem('gomail_token',token);
showApp();
}catch(e){const el=document.getElementById('lerr');el.textContent=e.message||'Login failed';el.style.display='';}
}
function logout(){localStorage.removeItem('gomail_token');token='';showLogin();}
function showLogin(){document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';}
async function showApp(){
document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';
const me=await api('/me');if(!me)return;
document.getElementById('me-email').textContent=me.email;
loadFolders();
}
async function loadFolders(){
const folders=await api('/folders');if(!folders)return;
document.getElementById('folder-list').innerHTML=folders.map(f=>
`<div class="nav-item ${f.id===currentFolder?'active':''}" onclick="selectFolder('${f.id}')">
${f.display_name} ${f.unread_count>0?`<span class="badge" style="background:#7c3aed;color:#fff">${f.unread_count}</span>`:''}
</div>`).join('');
loadMessages(currentFolder);
}
function selectFolder(id){
currentFolder=id;
document.getElementById('view-mail').style.display='flex';
document.getElementById('view-quarantine').style.display='none';
loadFolders();
}
async function loadMessages(folderID){
const msgs=await api('/folders/'+folderID+'/messages');if(!msgs)return;
document.getElementById('msg-list').innerHTML=msgs.length?msgs.map(m=>{
const unread=!(m.Flags||[]).includes('\\Seen');
return `<div class="msg-row ${unread?'unread':''}" onclick="viewMessage('${folderID}','${m.ID}')">
<div style="color:#e2e8f0">${esc(m.From||'(unknown)')}</div>
<div style="color:#94a3b8">${esc(m.Subject||'(no subject)')}</div>
</div>`;
}).join(''):'<div style="padding:20px;color:#475569;text-align:center">No messages</div>';
}
async function viewMessage(folderID,id){
const msg=await api('/messages/'+folderID+'/'+id);if(!msg)return;
document.getElementById('msg-view').innerHTML=`
<div style="border-bottom:1px solid #334155;padding-bottom:12px;margin-bottom:12px">
<div style="font-size:18px;font-weight:700;color:#fff">${esc(msg.Subject||'(no subject)')}</div>
<div style="color:#94a3b8;font-size:13px;margin-top:4px">From: ${esc(msg.From)}</div>
<div style="color:#94a3b8;font-size:13px">To: ${esc(msg.To)}</div>
</div>
<pre style="white-space:pre-wrap;font-family:inherit;color:#cbd5e1;font-size:13px">${esc(bodyOf(msg.Raw))}</pre>
<div style="margin-top:16px">
<button onclick="deleteMessage('${folderID}','${id}')" class="btn btn-ghost">🗑 Delete</button>
</div>`;
api('/messages/'+folderID+'/'+id+'/flags',{method:'PUT',body:JSON.stringify({Flags:['\\Seen']})});
}
function bodyOf(raw){
if(!raw)return'';
const decoded=atob(raw);
const idx=decoded.indexOf('\r\n\r\n');
return idx>=0?decoded.slice(idx+4):decoded;
}
async function deleteMessage(folderID,id){
await api('/messages/'+folderID+'/'+id,{method:'DELETE'});
loadMessages(folderID);
document.getElementById('msg-view').innerHTML='<div style="color:#475569;text-align:center;margin-top:60px">Select a message</div>';
}
function openCompose(){document.getElementById('compose-modal').style.display='flex';}
function closeCompose(){document.getElementById('compose-modal').style.display='none';}
async function sendMessage(){
const to=document.getElementById('c-to').value.split(',').map(s=>s.trim());
const subject=document.getElementById('c-subject').value;
const body=document.getElementById('c-body').value;
try{
await api('/messages',{method:'POST',body:JSON.stringify({to,subject,body})});
closeCompose();
document.getElementById('c-to').value='';document.getElementById('c-subject').value='';document.getElementById('c-body').value='';
}catch(e){alert('Send failed: '+(e.error||e.message));}
}
async function showQuarantine(){
document.getElementById('view-mail').style.display='none';
document.getElementById('view-quarantine').style.display='block';
const entries=await api('/quarantine');if(!entries)return;
document.getElementById('quarantine-list').innerHTML=entries.length?entries.map(e=>`
<div style="background:#1e293b;border:1px solid #334155;border-radius:10px;padding:14px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center">
<div><div style="color:#e2e8f0;font-size:13px">Reason: ${esc(e.Reason||'—')}</div>
<div style="color:#64748b;font-size:12px">Held: ${e.CreatedAt}</div></div>
<button onclick="releaseQ('${e.ID}')" class="btn btn-primary">Release</button>
</div>`).join(''):'<div style="color:#475569;text-align:center;padding:40px">🎉 Nothing held</div>';
}
async function releaseQ(id){
try{await api('/quarantine/'+id+'/release',{method:'POST'});showQuarantine();}
catch(e){alert('Release failed: '+(e.error||e.message));}
}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
async function boot(){
if(!token){showLogin();return;}
try{const me=await api('/me');if(me)showApp();else showLogin();}catch{showLogin();}
}
boot();
</script>
</body>
</html>
+137
View File
@@ -0,0 +1,137 @@
// Package webtoken implements minimal JWT issuing/verification (HS256 only)
// for webmail/admin session tokens — hand-rolled on stdlib crypto/hmac
// rather than a third-party JWT library, matching the project's
// dependency-free principle. Supports exactly what session tokens need:
// a subject (user ID), an expiry, and tamper-evident signing. No JWK sets,
// no algorithm negotiation, no other algorithms — HS256 with a server-side
// secret is the right tool for "did we issue this token", nothing more.
package webtoken
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
type Claims struct {
Subject string `json:"sub"`
TenantID string `json:"tenant_id,omitempty"`
Role string `json:"role,omitempty"`
Purpose string `json:"purpose,omitempty"` // e.g. "mfa_pending", "password_reset" — empty means a normal full session
Ctx string `json:"ctx,omitempty"` // purpose-specific binding, e.g. a Fingerprint of the password hash for password_reset
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
}
var header = base64URLEncode([]byte(`{"alg":"HS256","typ":"JWT"}`))
// Issue creates a signed token for the given subject, valid for ttl.
func Issue(secret, subject, tenantID, role string, ttl time.Duration) (string, error) {
return IssueWithPurpose(secret, subject, tenantID, role, "", ttl)
}
// IssueWithPurpose is Issue plus a purpose tag — used for tokens that are
// NOT a full session (MFA-pending, password-reset) so a caller checking
// claims.Purpose can refuse to treat them as one, even though they're
// structurally the same JWT and share the same verification path.
func IssueWithPurpose(secret, subject, tenantID, role, purpose string, ttl time.Duration) (string, error) {
now := time.Now().UTC()
claims := Claims{
Subject: subject, TenantID: tenantID, Role: role, Purpose: purpose,
IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(),
}
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal claims: %w", err)
}
payload := base64URLEncode(payloadJSON)
signingInput := header + "." + payload
sig := sign(secret, signingInput)
return signingInput + "." + sig, nil
}
// IssueResetToken issues a password_reset purpose token bound to
// passwordHashFingerprint (see Fingerprint) — the fingerprint of the
// user's password hash at issuance time. Because resetting the password
// changes that hash, FingerprintMatches will reject the same token on any
// second use, giving single-use semantics with no server-side token store.
func IssueResetToken(secret, subject, tenantID, role, passwordHashFingerprint string, ttl time.Duration) (string, error) {
now := time.Now().UTC()
claims := Claims{
Subject: subject, TenantID: tenantID, Role: role, Purpose: "password_reset",
Ctx: passwordHashFingerprint,
IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(),
}
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal claims: %w", err)
}
payload := base64URLEncode(payloadJSON)
signingInput := header + "." + payload
sig := sign(secret, signingInput)
return signingInput + "." + sig, nil
}
// Fingerprint returns a short, non-reversible fingerprint of s (e.g. a
// password hash), suitable for embedding in a token to detect whether the
// underlying value has changed since the token was issued.
func Fingerprint(s string) string {
sum := sha256.Sum256([]byte(s))
return base64.RawURLEncoding.EncodeToString(sum[:8])
}
// FingerprintMatches reports, in constant time, whether s's Fingerprint
// matches the one embedded in claims.Ctx.
func FingerprintMatches(claims *Claims, s string) bool {
return subtle.ConstantTimeCompare([]byte(Fingerprint(s)), []byte(claims.Ctx)) == 1
}
// Verify checks signature and expiry, returning the claims if valid.
func Verify(secret, token string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("malformed token")
}
signingInput := parts[0] + "." + parts[1]
expectedSig := sign(secret, signingInput)
// Constant-time comparison — avoids leaking signature validity via timing.
if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(parts[2])) != 1 {
return nil, fmt.Errorf("invalid signature")
}
payloadJSON, err := base64URLDecode(parts[1])
if err != nil {
return nil, fmt.Errorf("decode payload: %w", err)
}
var claims Claims
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, fmt.Errorf("unmarshal claims: %w", err)
}
if time.Now().UTC().Unix() > claims.ExpiresAt {
return nil, fmt.Errorf("token expired")
}
return &claims, nil
}
func sign(secret, signingInput string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingInput))
return base64URLEncode(mac.Sum(nil))
}
func base64URLEncode(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
func base64URLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}