mailboxconfig (IMP-07) bekommt eine quota_bytes-Spalte statt einer eigenen Tabelle — ein Postfach ist bereits eindeutig über (tenant_slug, name) identifiziert. SetQuotaBytes/LimitBytes, 0 = unbegrenzt (Standardwert, keine Migration bestehender Postfächer nötig). LimitBytes erfüllt strukturell quota.LimitProvider. storage.ArchiveMailboxPrefix (ARC-04-Ergänzung, Präfix ALLER Jahre eines Postfachs) und storage.UsageCounter: realer Speicherverbrauch durch echtes S3-Listing im physisch getrennten Mandanten-Bucket (ARC-06) — kein separat gepflegter Zählerstand. Neues Paket mail/internal/quota: Checker verbindet LimitProvider und UsageProvider. Kein konfiguriertes Limit = immer erlaubt (Core-LIC-05- Quota läuft unabhängig weiter — beide Ebenen bewusst unabhängig durchgesetzt, bekannter Fehler vermieden). smtp.QuotaChecker (schmale Schnittstelle, keine Paketkopplung an quota) wird in handleRcptTo geprüft, VOR der Datenübertragung: 552 (RFC 5321 "exceeded storage allocation") bei Überschreitung, Session bleibt nutzbar. nil-Checker erhält bisheriges Verhalten unverändert. Alle drei Pflichtprüfungen mit echten Nachweisen: Quota-Überschreitung liefert 552, Session bleibt funktionsfähig; ein anderes Postfach desselben Tenants läuft währenddessen vollständig normal durch; vollständiger Ende-zu-Ende-Integrationstest gegen reale Postgres- und MinIO-Instanzen — 5000 echte Bytes abgelegt, real gemessen, Limit knapp darunter/darüber gesetzt, SMTP reagiert jeweils korrekt auf den tatsächlichen gemessenen Wert. Dabei einen echten Cleanup-Fehler gefunden und behoben (defer schloss den Pool vor dem zugehörigen t.Cleanup, verwaiste Testdaten blieben zurück). go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet.
193 lines
5.9 KiB
Go
193 lines
5.9 KiB
Go
package smtp
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
|
)
|
|
|
|
func (s *Session) handleHelo(verb, arg string) bool {
|
|
if strings.TrimSpace(arg) == "" {
|
|
return s.reply(501, verb+" requires a domain/address") == nil
|
|
}
|
|
// HELO/EHLO setzt den Envelope zurück, falls bereits einer im
|
|
// Aufbau war (RFC 5321 §4.1.1.1).
|
|
s.from = ""
|
|
s.to = nil
|
|
s.state = Ready
|
|
|
|
if verb == "EHLO" {
|
|
lines := []string{"nexarch-mail greets " + arg, "8BITMIME"}
|
|
if s.tlsConfig != nil && !s.tlsActive {
|
|
lines = append(lines, "STARTTLS")
|
|
}
|
|
return s.replyMultiline(250, lines) == nil
|
|
}
|
|
return s.reply(250, "nexarch-mail greets "+arg) == nil
|
|
}
|
|
|
|
// handleStartTLS implementiert STARTTLS (RFC 3207): TLS-Aufwertung der
|
|
// bestehenden Klartextverbindung.
|
|
func (s *Session) handleStartTLS(ctx context.Context) bool {
|
|
if s.tlsConfig == nil {
|
|
return s.reply(454, "TLS not available") == nil
|
|
}
|
|
if s.tlsActive {
|
|
return s.reply(503, "already using TLS") == nil
|
|
}
|
|
if err := s.reply(220, "Ready to start TLS"); err != nil {
|
|
return false
|
|
}
|
|
|
|
tlsConn, err := tlscert.UpgradeServer(ctx, s.conn, s.tlsConfig)
|
|
if err != nil {
|
|
// Handshake fehlgeschlagen -> Verbindung beenden, kein Rückfall
|
|
// auf Klartext.
|
|
return false
|
|
}
|
|
s.conn = tlsConn
|
|
// Reader/Writer NEU aufsetzen: verhindert, dass vor dem Handshake
|
|
// gepufferte Klartextdaten nach dem Wechsel noch als Kommandos
|
|
// verarbeitet werden (Command-Injection-Schutz).
|
|
s.reader = bufio.NewReaderSize(tlsConn, maxCommandLineBytes)
|
|
s.writer = bufio.NewWriter(tlsConn)
|
|
s.tlsActive = true
|
|
// Envelope nach STARTTLS zurücksetzen (RFC 3207 §4.2).
|
|
s.from = ""
|
|
s.to = nil
|
|
s.state = Ready
|
|
return true
|
|
}
|
|
|
|
// handleMailFrom ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
|
// die Absenderadresse wird vor der Annahme validiert.
|
|
func (s *Session) handleMailFrom(arg string) bool {
|
|
if s.state == Greeting {
|
|
return s.reply(503, "send HELO/EHLO first") == nil
|
|
}
|
|
addr, err := parseMailAddressArg(arg, "FROM")
|
|
if err != nil {
|
|
return s.reply(501, "invalid MAIL FROM syntax") == nil
|
|
}
|
|
if err := validateAddress(addr); err != nil {
|
|
// Akzeptanzkriterium 3: ungültige Absenderdaten -> saubere
|
|
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
|
|
return s.reply(553, "invalid sender address") == nil
|
|
}
|
|
if s.acceptLimiter != nil {
|
|
if ok, retryAfter := s.acceptLimiter.Allow(s.tenantID, addr+"|"+s.sourceAddr()); !ok {
|
|
// Akzeptanzkriterium 1/3 (ING-09): definierte, temporäre
|
|
// Ablehnung (4xx = "try again later") MIT Wartezeit-Hinweis
|
|
// statt Verbindungsabbruch ohne Erklärung.
|
|
return s.reply(451, fmt.Sprintf("rate limit exceeded for sender, retry in %.1fs", retryAfter.Seconds())) == nil
|
|
}
|
|
}
|
|
s.from = addr
|
|
s.to = nil
|
|
s.state = MailFromSet
|
|
return s.reply(250, "OK") == nil
|
|
}
|
|
|
|
// handleRcptTo ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
|
// jede Empfängeradresse wird vor der Annahme validiert; mehrere RCPT TO
|
|
// sind erlaubt.
|
|
func (s *Session) handleRcptTo(ctx context.Context, arg string) bool {
|
|
if s.state != MailFromSet && s.state != RcptToSet {
|
|
return s.reply(503, "send MAIL FROM first") == nil
|
|
}
|
|
addr, err := parseMailAddressArg(arg, "TO")
|
|
if err != nil {
|
|
return s.reply(501, "invalid RCPT TO syntax") == nil
|
|
}
|
|
if err := validateAddress(addr); err != nil {
|
|
// Akzeptanzkriterium 3: ungültige Empfängerdaten -> saubere
|
|
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
|
|
return s.reply(553, "invalid recipient address") == nil
|
|
}
|
|
if s.quotaChecker != nil {
|
|
// ARC-09 Akzeptanzkriterium 2: ein Postfach am Limit lehnt neue
|
|
// eingehende Mail mit klarer, protokollgerechter SMTP-Fehlermeldung
|
|
// ab (552 = RFC 5321 "exceeded storage allocation") — an RCPT TO
|
|
// geprüft, BEVOR die Nachricht überhaupt übertragen wird, statt
|
|
// nach vollständigem DATA-Empfang zu verwerfen. addr (der
|
|
// Empfänger) ist der Postfachbezug — dasselbe mailbox-Feld wie
|
|
// mail/internal/storage.ArchiveKey/mail/internal/mailboxconfig.
|
|
allowed, err := s.quotaChecker.Allowed(ctx, s.tenantID, addr)
|
|
if err != nil {
|
|
return s.reply(451, "unable to verify mailbox quota, try again later") == nil
|
|
}
|
|
if !allowed {
|
|
return s.reply(552, "mailbox quota exceeded for "+addr) == nil
|
|
}
|
|
}
|
|
s.to = append(s.to, addr)
|
|
s.state = RcptToSet
|
|
return s.reply(250, "OK") == nil
|
|
}
|
|
|
|
func (s *Session) handleRset() bool {
|
|
s.from = ""
|
|
s.to = nil
|
|
if s.state != Greeting {
|
|
s.state = Ready
|
|
}
|
|
return s.reply(250, "OK") == nil
|
|
}
|
|
|
|
// handleData verlangt einen vollständig aufgebauten und validierten
|
|
// Envelope (Akzeptanzkriterium 1: Envelope UND Nachrichtengröße werden
|
|
// vor der Annahme geprüft) und liest die dot-gestuffte Nachricht bis zur
|
|
// Abschlusszeile ".".
|
|
func (s *Session) handleData(ctx context.Context) bool {
|
|
if s.state != RcptToSet {
|
|
return s.reply(503, "send MAIL FROM/RCPT TO first") == nil
|
|
}
|
|
if err := s.reply(354, "Start mail input; end with <CRLF>.<CRLF>"); err != nil {
|
|
return false
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
for {
|
|
line, err := s.readLine()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if line == "." {
|
|
break
|
|
}
|
|
// Byte-Stuffing rückgängig machen (RFC 5321 §4.5.2): eine Zeile,
|
|
// die mit "." beginnt, verliert genau diesen ersten Punkt.
|
|
line = strings.TrimPrefix(line, ".")
|
|
buf.WriteString(line)
|
|
buf.WriteString("\r\n")
|
|
|
|
if int64(buf.Len()) > s.maxMessageBytes {
|
|
// Akzeptanzkriterium 1: Nachrichtengröße wird VOR der
|
|
// endgültigen Annahme geprüft — sauberer Fehlercode statt
|
|
// unbegrenztem Pufferwachstum.
|
|
_ = s.drainUntilDot()
|
|
s.from = ""
|
|
s.to = nil
|
|
s.state = Ready
|
|
return s.reply(552, "message size exceeds fixed maximum message size") == nil
|
|
}
|
|
}
|
|
|
|
envelope := Envelope{From: s.from, To: s.to}
|
|
raw := buf.Bytes()
|
|
s.from = ""
|
|
s.to = nil
|
|
s.state = Ready
|
|
|
|
if s.sink != nil {
|
|
if err := s.sink.Accept(ctx, envelope, raw); err != nil {
|
|
return s.reply(451, "unable to accept message, try again later") == nil
|
|
}
|
|
}
|
|
return s.reply(250, "OK: message accepted") == nil
|
|
}
|