first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package admin
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static/index.html
|
||||
var StaticFS embed.FS
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
if(!token){showLogin();}else{showApp();}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)`,
|
||||
}
|
||||
@@ -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;
|
||||
`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ")")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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, `"`, `\"`) + `"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, `"`, `'`)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package webmail
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static/index.html
|
||||
var StaticFS embed.FS
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
async function boot(){
|
||||
if(!token){showLogin();return;}
|
||||
try{const me=await api('/me');if(me)showApp();else showLogin();}catch{showLogin();}
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user