happyDeliver/internal/receiver/receiver.go
2025-10-24 09:56:35 +07:00

203 lines
6.1 KiB
Go

// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package receiver
import (
"encoding/base32"
"encoding/json"
"fmt"
"io"
"log"
"regexp"
"strings"
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
// EmailReceiver handles incoming emails from the MTA
type EmailReceiver struct {
storage storage.Storage
config *config.Config
analyzer *analyzer.EmailAnalyzer
}
// NewEmailReceiver creates a new email receiver
func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver {
return &EmailReceiver{
storage: store,
config: cfg,
analyzer: analyzer.NewEmailAnalyzer(cfg),
}
}
// ProcessEmail reads an email from the reader, analyzes it, and stores the results
func (r *EmailReceiver) ProcessEmail(emailData io.Reader, recipientEmail string) error {
// Read the entire email
rawEmail, err := io.ReadAll(emailData)
if err != nil {
return fmt.Errorf("failed to read email: %w", err)
}
return r.ProcessEmailBytes(rawEmail, recipientEmail)
}
// ProcessEmailBytes processes an email from a byte slice
func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string) error {
log.Printf("Received email for %s (%d bytes)", recipientEmail, len(rawEmail))
// Extract test ID from recipient email address
testID, err := r.extractTestID(recipientEmail)
if err != nil {
return fmt.Errorf("failed to extract test ID: %w", err)
}
log.Printf("Extracted test ID: %s", testID)
// Check if a report already exists for this test ID
reportExists, err := r.storage.ReportExists(testID)
if err != nil {
return fmt.Errorf("failed to check report existence: %w", err)
}
if reportExists {
log.Printf("Report already exists for test %s, skipping analysis", testID)
return nil
}
log.Printf("Analyzing email for test %s", testID)
// Analyze the email using the shared analyzer
result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID)
if err != nil {
return fmt.Errorf("failed to analyze email: %w", err)
}
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
if err != nil {
return fmt.Errorf("failed to marshal report: %w", err)
}
// Store the report
if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil {
return fmt.Errorf("failed to store report: %w", err)
}
log.Printf("Report stored successfully for test %s", testID)
return nil
}
// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID
// Hyphens are ignored during decoding
func base32ToUUID(encoded string) (uuid.UUID, error) {
// Remove hyphens for decoding
encoded = strings.ReplaceAll(encoded, "-", "")
// Convert to uppercase for Base32 decoding
encoded = strings.ToUpper(encoded)
// Decode from Base32
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err)
}
// Ensure we have exactly 16 bytes for UUID
if len(decoded) != 16 {
return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded))
}
// Convert bytes to UUID
var id uuid.UUID
copy(id[:], decoded)
return id, nil
}
// extractTestID extracts the UUID from the test email address
// Expected format: test-<base32-uuid>@domain.com
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
email = strings.Trim(email, "<>")
// Extract the local part (before @)
parts := strings.Split(email, "@")
if len(parts) != 2 {
return uuid.Nil, fmt.Errorf("invalid email format: %s", email)
}
localPart := parts[0]
// Remove the prefix (e.g., "test-")
if !strings.HasPrefix(localPart, r.config.Email.TestAddressPrefix) {
return uuid.Nil, fmt.Errorf("email does not have expected prefix: %s", email)
}
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
// Decode Base32 to UUID
testID, err := base32ToUUID(uuidStr)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err)
}
return testID, nil
}
// ExtractRecipientFromHeaders attempts to extract the recipient email from email headers
// This is useful when the email is piped and we need to determine the recipient
func ExtractRecipientFromHeaders(rawEmail []byte) (string, error) {
emailStr := string(rawEmail)
// Look for common recipient headers
headerPatterns := []string{
`(?i)^To:\s*(.+)$`,
`(?i)^X-Original-To:\s*(.+)$`,
`(?i)^Delivered-To:\s*(.+)$`,
`(?i)^Envelope-To:\s*(.+)$`,
}
for _, pattern := range headerPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(emailStr)
if len(matches) > 1 {
recipient := strings.TrimSpace(matches[1])
// Clean up the email address
recipient = strings.Trim(recipient, "<>")
// Take only the first email if there are multiple
if idx := strings.Index(recipient, ","); idx != -1 {
recipient = recipient[:idx]
}
if recipient != "" {
return recipient, nil
}
}
}
return "", fmt.Errorf("could not extract recipient from email headers")
}