/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/

package spf

import (
	"context"
	"errors"
	"fmt"
	"net"
	"runtime/debug"
	"runtime/trace"

	"blitiri.com.ar/go/spf"
	"github.com/emersion/go-message/textproto"
	"github.com/emersion/go-msgauth/authres"
	"github.com/emersion/go-msgauth/dmarc"
	"github.com/foxcpp/maddy/framework/address"
	"github.com/foxcpp/maddy/framework/buffer"
	"github.com/foxcpp/maddy/framework/config"
	modconfig "github.com/foxcpp/maddy/framework/config/module"
	"github.com/foxcpp/maddy/framework/container"
	"github.com/foxcpp/maddy/framework/dns"
	"github.com/foxcpp/maddy/framework/exterrors"
	"github.com/foxcpp/maddy/framework/log"
	"github.com/foxcpp/maddy/framework/module"
	"github.com/foxcpp/maddy/framework/module/modules"
	maddydmarc "github.com/foxcpp/maddy/internal/dmarc"
	"github.com/foxcpp/maddy/internal/target"
	"golang.org/x/net/idna"
)

const modName = "check.spf"

type Check struct {
	instName     string
	enforceEarly bool

	noneAction     modconfig.FailAction
	neutralAction  modconfig.FailAction
	failAction     modconfig.FailAction
	softfailAction modconfig.FailAction
	permerrAction  modconfig.FailAction
	temperrAction  modconfig.FailAction

	log      *log.Logger
	resolver dns.Resolver
}

func New(c *container.C, _, instName string) (module.Module, error) {
	return &Check{
		instName: instName,
		log:      c.DefaultLogger.Sublogger(modName),
		resolver: dns.DefaultResolver(),
	}, nil
}

func (c *Check) Name() string {
	return modName
}

func (c *Check) InstanceName() string {
	return c.instName
}

func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
	cfg.Bool("debug", true, false, &c.log.Debug)
	cfg.Bool("enforce_early", true, false, &c.enforceEarly)
	cfg.Custom("none_action", false, false,
		func() (interface{}, error) {
			return modconfig.FailAction{}, nil
		}, modconfig.FailActionDirective, &c.noneAction)
	cfg.Custom("neutral_action", false, false,
		func() (interface{}, error) {
			return modconfig.FailAction{}, nil
		}, modconfig.FailActionDirective, &c.neutralAction)
	cfg.Custom("fail_action", false, false,
		func() (interface{}, error) {
			return modconfig.FailAction{Quarantine: true}, nil
		}, modconfig.FailActionDirective, &c.failAction)
	cfg.Custom("softfail_action", false, false,
		func() (interface{}, error) {
			return modconfig.FailAction{}, nil
		}, modconfig.FailActionDirective, &c.softfailAction)
	cfg.Custom("permerr_action", false, false,
		func() (interface{}, error) {
			return modconfig.FailAction{}, nil
		}, modconfig.FailActionDirective, &c.permerrAction)
	cfg.Custom("temperr_action", false, false,
		func() (interface{}, error) {
			return modconfig.FailAction{}, nil
		}, modconfig.FailActionDirective, &c.temperrAction)
	_, err := cfg.Process()
	if err != nil {
		return err
	}

	return nil
}

type spfRes struct {
	res spf.Result
	err error
}

type state struct {
	c        *Check
	msgMeta  *module.MsgMetadata
	spfFetch chan spfRes
	log      *log.Logger

	skip bool
}

func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
	return &state{
		c:        c,
		msgMeta:  msgMeta,
		spfFetch: make(chan spfRes, 1),
		log:      target.DeliveryLogger(c.log, msgMeta),
	}, nil
}

func (s *state) spfResult(res spf.Result, err error) module.CheckResult {
	_, fromDomain, _ := address.Split(s.msgMeta.OriginalFrom)
	spfAuth := &authres.SPFResult{
		Value: authres.ResultNone,
		Helo:  s.msgMeta.Conn.Hostname,
		From:  fromDomain,
	}

	if err != nil {
		spfAuth.Reason = err.Error()
	} else if res == spf.None {
		spfAuth.Reason = "no policy"
	}

	switch res {
	case spf.None:
		spfAuth.Value = authres.ResultNone
		return s.c.noneAction.Apply(module.CheckResult{
			Reason: &exterrors.SMTPError{
				Code:         550,
				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
				Message:      "No SPF policy",
				CheckName:    modName,
				Err:          err,
			},
			AuthResult: []authres.Result{spfAuth},
		})
	case spf.Neutral:
		spfAuth.Value = authres.ResultNeutral
		return s.c.neutralAction.Apply(module.CheckResult{
			Reason: &exterrors.SMTPError{
				Code:         550,
				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
				Message:      "Neutral SPF result is not permitted",
				CheckName:    modName,
				Err:          err,
			},
			AuthResult: []authres.Result{spfAuth},
		})
	case spf.Pass:
		spfAuth.Value = authres.ResultPass
		return module.CheckResult{AuthResult: []authres.Result{spfAuth}}
	case spf.Fail:
		spfAuth.Value = authres.ResultFail
		return s.c.failAction.Apply(module.CheckResult{
			Reason: &exterrors.SMTPError{
				Code:         550,
				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
				Message:      "SPF authentication failed",
				CheckName:    modName,
				Err:          err,
			},
			AuthResult: []authres.Result{spfAuth},
		})
	case spf.SoftFail:
		spfAuth.Value = authres.ResultSoftFail
		return s.c.softfailAction.Apply(module.CheckResult{
			Reason: &exterrors.SMTPError{
				Code:         550,
				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
				Message:      "SPF authentication soft-failed",
				CheckName:    modName,
				Err:          err,
			},
			AuthResult: []authres.Result{spfAuth},
		})
	case spf.TempError:
		spfAuth.Value = authres.ResultTempError
		return s.c.temperrAction.Apply(module.CheckResult{
			Reason: &exterrors.SMTPError{
				Code:         451,
				EnhancedCode: exterrors.EnhancedCode{4, 7, 23},
				Message:      "SPF authentication failed with a temporary error",
				CheckName:    modName,
				Err:          err,
			},
			AuthResult: []authres.Result{spfAuth},
		})
	case spf.PermError:
		spfAuth.Value = authres.ResultPermError
		return s.c.permerrAction.Apply(module.CheckResult{
			Reason: &exterrors.SMTPError{
				Code:         550,
				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
				Message:      "SPF authentication failed with a permanent error",
				CheckName:    modName,
				Err:          err,
			},
			AuthResult: []authres.Result{spfAuth},
		})
	}

	return module.CheckResult{
		Reason: &exterrors.SMTPError{
			Code:         550,
			EnhancedCode: exterrors.EnhancedCode{4, 7, 23},
			Message:      fmt.Sprintf("Unknown SPF status: %s", res),
			CheckName:    modName,
			Err:          err,
		},
		AuthResult: []authres.Result{spfAuth},
	}
}

func (s *state) relyOnDMARC(ctx context.Context, hdr textproto.Header) bool {
	fromDomain, err := maddydmarc.ExtractFromDomain(hdr)
	if err != nil {
		s.log.Error("DMARC domains extract", err)
		return false
	}

	policyDomain, record, err := maddydmarc.FetchRecord(ctx, s.c.resolver, fromDomain)
	if err != nil {
		s.log.Error("DMARC fetch", err, "from_domain", fromDomain)
		return false
	}
	if record == nil {
		return false
	}

	policy := record.Policy
	// We check for subdomain using non-equality since fromDomain is either the
	// subdomain of policyDomain or policyDomain itself (due to the way
	// FetchRecord handles it).
	if !dns.Equal(policyDomain, fromDomain) && record.SubdomainPolicy != "" {
		policy = record.SubdomainPolicy
	}

	return policy != dmarc.PolicyNone
}

func prepareMailFrom(from string) (string, error) {
	// INTERNATIONALIZATION: RFC 8616, Section 4
	// Hostname is already in A-labels per SMTPUTF8 requirement.
	// MAIL FROM domain should be converted to A-labels before doing
	// anything.
	fromMbox, fromDomain, err := address.Split(from)
	if err != nil {
		return "", &exterrors.SMTPError{
			Code:         550,
			EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
			Message:      "Malformed address",
			CheckName:    "spf",
		}
	}
	fromDomain, err = idna.ToASCII(fromDomain)
	if err != nil {
		return "", &exterrors.SMTPError{
			Code:         550,
			EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
			Message:      "Malformed address",
			CheckName:    "spf",
		}
	}

	// %{s} and %{l} do not match anything if it is non-ASCII.
	// Since spf lib does not seem to care, strip it.
	if !address.IsASCII(fromMbox) {
		fromMbox = ""
	}

	return fromMbox + "@" + dns.FQDN(fromDomain), nil
}

func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
	defer trace.StartRegion(ctx, "check.spf/CheckConnection").End()

	if s.msgMeta.Conn == nil {
		s.skip = true
		s.log.Println("locally generated message, skipping")
		return module.CheckResult{}
	}

	ip, ok := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
	if !ok {
		s.skip = true
		s.log.Println("non-IP SrcAddr")
		return module.CheckResult{}
	}

	mailFromOriginal := s.msgMeta.OriginalFrom
	if mailFromOriginal == "" {
		// RFC 7208 Section 2.4.
		// >When the reverse-path is null, this document
		// >defines the "MAIL FROM" identity to be the mailbox composed of the
		// >local-part "postmaster" and the "HELO" identity (which might or might
		// >not have been checked separately before).
		mailFromOriginal = "postmaster@" + s.msgMeta.Conn.Hostname
	}

	mailFrom, err := prepareMailFrom(mailFromOriginal)
	if err != nil {
		s.skip = true
		return module.CheckResult{
			Reason: err,
			Reject: true,
		}
	}

	if s.c.enforceEarly {
		res, err := spf.CheckHostWithSender(ip.IP,
			dns.FQDN(s.msgMeta.Conn.Hostname), mailFrom,
			spf.WithContext(ctx), spf.WithResolver(s.c.resolver))
		s.log.Debugf("result: %s (%v)", res, err)
		return s.spfResult(res, err)
	}

	// We start evaluation in parallel to other message processing,
	// once we get the body, we fetch DMARC policy and see if it exists
	// and not p=none. In that case, we rely on DMARC alignment to define result.
	// Otherwise, we take action based on SPF only.

	go func() {
		defer func() {
			if err := recover(); err != nil {
				stack := debug.Stack()
				log.Printf("panic during spf.CheckHostWithSender: %v\n%s", err, stack)
				close(s.spfFetch)
			}
		}()

		defer trace.StartRegion(ctx, "check.spf/CheckConnection (Async)").End()

		res, err := spf.CheckHostWithSender(ip.IP, dns.FQDN(s.msgMeta.Conn.Hostname), mailFrom,
			spf.WithContext(ctx), spf.WithResolver(s.c.resolver))
		s.log.Debugf("result: %s (%v)", res, err)
		s.spfFetch <- spfRes{res, err}
	}()

	return module.CheckResult{}
}

func (s *state) CheckSender(ctx context.Context, mailFrom string) module.CheckResult {
	return module.CheckResult{}
}

func (s *state) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult {
	return module.CheckResult{}
}

func (s *state) CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) module.CheckResult {
	if s.c.enforceEarly || s.skip {
		// Already applied in CheckConnection.
		return module.CheckResult{}
	}

	defer trace.StartRegion(ctx, "check.spf/CheckBody").End()

	res, ok := <-s.spfFetch
	if !ok {
		return module.CheckResult{
			Reject: true,
			Reason: exterrors.WithTemporary(
				exterrors.WithFields(errors.New("panic recovered"), map[string]interface{}{
					"check":    "spf",
					"smtp_msg": "Internal error during policy check",
				}),
				true,
			),
		}
	}
	if s.relyOnDMARC(ctx, header) {
		if res.res != spf.Pass {
			s.log.Msg("deferring action due to a DMARC policy", "result", res.res, "err", res.err)
		} else {
			s.log.DebugMsg("deferring action due to a DMARC policy", "result", res.res, "err", res.err)
		}

		checkRes := s.spfResult(res.res, res.err)
		checkRes.Quarantine = false
		checkRes.Reject = false
		return checkRes
	}

	return s.spfResult(res.res, res.err)
}

func (s *state) Close() error {
	return nil
}

func init() {
	modules.Register(modName, New)
}
