210 lines
5.3 KiB
Go
210 lines
5.3 KiB
Go
package usecase
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"strings"
|
|
"time"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
"lambdamail/protocols/internal/domain/entity"
|
|
"lambdamail/protocols/internal/domain/valueobject"
|
|
)
|
|
|
|
type OutboundWorkerUseCase struct {
|
|
outboundRepo port.OutboundRepository
|
|
mxResolver port.MXResolver
|
|
blobReader port.BlobReader
|
|
inboundUC *ProcessInboundEmailUseCase
|
|
mailboxes port.MailboxRepository
|
|
localHost string
|
|
}
|
|
|
|
func NewOutboundWorkerUseCase(
|
|
outboundRepo port.OutboundRepository,
|
|
mxResolver port.MXResolver,
|
|
blobReader port.BlobReader,
|
|
inboundUC *ProcessInboundEmailUseCase,
|
|
mailboxes port.MailboxRepository,
|
|
localHost string,
|
|
) *OutboundWorkerUseCase {
|
|
if localHost == "" {
|
|
localHost = "mail.lambdamail.local"
|
|
}
|
|
return &OutboundWorkerUseCase{
|
|
outboundRepo: outboundRepo,
|
|
mxResolver: mxResolver,
|
|
blobReader: blobReader,
|
|
inboundUC: inboundUC,
|
|
mailboxes: mailboxes,
|
|
localHost: localHost,
|
|
}
|
|
}
|
|
|
|
func (w *OutboundWorkerUseCase) ProcessBatch(ctx context.Context, workerID string, limit int) (int, error) {
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
jobs, err := w.outboundRepo.FetchNextReady(ctx, workerID, limit)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("fetch ready jobs: %w", err)
|
|
}
|
|
|
|
processed := 0
|
|
for _, job := range jobs {
|
|
w.processSingleJob(ctx, job)
|
|
processed++
|
|
}
|
|
|
|
return processed, nil
|
|
}
|
|
|
|
func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entity.OutboundJob) {
|
|
job.Attempt++
|
|
|
|
payload, err := w.blobReader.ReadByID(ctx, job.BlobID)
|
|
if err != nil {
|
|
w.handleFailure(ctx, job, fmt.Sprintf("read blob %s: %v", job.BlobID, err), true)
|
|
return
|
|
}
|
|
|
|
mxHosts, err := w.mxResolver.LookupMX(ctx, job.DestinationDomain)
|
|
if err != nil || len(mxHosts) == 0 {
|
|
mxHosts = []string{job.DestinationDomain}
|
|
}
|
|
|
|
var lastErr error
|
|
delivered := false
|
|
|
|
for _, mxHost := range mxHosts {
|
|
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload); err == nil {
|
|
delivered = true
|
|
break
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
}
|
|
|
|
if delivered {
|
|
job.Status = entity.OutboundJobStatusDelivered
|
|
job.LastError = ""
|
|
job.TlsPolicyUsed = "opportunistic"
|
|
_ = w.outboundRepo.UpdateJob(ctx, job)
|
|
return
|
|
}
|
|
|
|
errStr := "delivery failed"
|
|
if lastErr != nil {
|
|
errStr = lastErr.Error()
|
|
}
|
|
|
|
isPermanent := strings.HasPrefix(errStr, "5")
|
|
w.handleFailure(ctx, job, errStr, isPermanent)
|
|
}
|
|
|
|
func (w *OutboundWorkerUseCase) handleFailure(ctx context.Context, job *entity.OutboundJob, errStr string, isPermanent bool) {
|
|
now := time.Now()
|
|
nextAttemptAt, sendDelayDsn, maxExceeded := entity.CalculateNextBackoff(now, job.Attempt, job.ExpiresAt)
|
|
|
|
if isPermanent || maxExceeded {
|
|
job.Status = entity.OutboundJobStatusBounced
|
|
job.LastError = errStr
|
|
_ = w.outboundRepo.UpdateJob(ctx, job)
|
|
|
|
w.sendDsn(ctx, valueobject.DsnActionFailed, job, errStr)
|
|
return
|
|
}
|
|
|
|
job.Status = entity.OutboundJobStatusDeferred
|
|
job.NextAttemptAt = nextAttemptAt
|
|
job.LastError = errStr
|
|
|
|
if sendDelayDsn && !job.DelayDsnSent {
|
|
w.sendDsn(ctx, valueobject.DsnActionDelayed, job, errStr)
|
|
job.DelayDsnSent = true
|
|
}
|
|
|
|
_ = w.outboundRepo.UpdateJob(ctx, job)
|
|
}
|
|
|
|
func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.DsnAction, job *entity.OutboundJob, reason string) {
|
|
if w.inboundUC == nil || job.MailboxID == nil {
|
|
return
|
|
}
|
|
|
|
dsnBytes, isLoop := valueobject.BuildDsnReport(action, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason)
|
|
if isLoop || dsnBytes == nil {
|
|
return
|
|
}
|
|
|
|
if w.mailboxes != nil {
|
|
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
|
|
if err == nil && len(targets) > 0 {
|
|
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
|
|
Sender: "postmaster@lambdamail.local",
|
|
Recipients: targets,
|
|
RecipientAddresses: []string{job.EnvelopeFrom},
|
|
Body: bytes.NewReader(dsnBytes),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte) error {
|
|
addr := mxHost
|
|
if _, _, err := net.SplitHostPort(mxHost); err != nil {
|
|
addr = net.JoinHostPort(mxHost, "25")
|
|
}
|
|
d := net.Dialer{Timeout: 10 * time.Second}
|
|
|
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("dial tcp %s: %w", addr, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
client, err := smtp.NewClient(conn, mxHost)
|
|
if err != nil {
|
|
return fmt.Errorf("smtp client handshake: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if err := client.Hello(w.localHost); err != nil {
|
|
return fmt.Errorf("EHLO failed: %w", err)
|
|
}
|
|
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
config := &tls.Config{InsecureSkipVerify: true, ServerName: mxHost}
|
|
if err := client.StartTLS(config); err != nil {
|
|
return fmt.Errorf("STARTTLS failed: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := client.Mail(from); err != nil {
|
|
return fmt.Errorf("MAIL FROM failed: %w", err)
|
|
}
|
|
if err := client.Rcpt(to); err != nil {
|
|
return fmt.Errorf("RCPT TO failed: %w", err)
|
|
}
|
|
|
|
wWriter, err := client.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("DATA command failed: %w", err)
|
|
}
|
|
|
|
if _, err := wWriter.Write(payload); err != nil {
|
|
wWriter.Close()
|
|
return fmt.Errorf("write DATA payload failed: %w", err)
|
|
}
|
|
|
|
if err := wWriter.Close(); err != nil {
|
|
return fmt.Errorf("close DATA writer failed: %w", err)
|
|
}
|
|
|
|
return client.Quit()
|
|
}
|