Compare commits
	
		
			9 commits
		
	
	
		
			9625807106
			...
			bd8a3395e9
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| bd8a3395e9 | |||
| aa1fd99ffd | |||
| a00f64e43c | |||
| 57acca0621 | |||
| 787b0f99fc | |||
| 4ace8af18b | |||
| a2b696471c | |||
| cfebaf09e3 | |||
| 31f2102031 | 
					 9 changed files with 267 additions and 223 deletions
				
			
		
							
								
								
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -31,3 +31,4 @@ dist/ | |||
| 
 | ||||
| # smtp2shoutrrr | ||||
| config.toml | ||||
| .aider* | ||||
|  |  | |||
|  | @ -1,3 +1,5 @@ | |||
| # This file is used directly by the goreleaser build | ||||
| # It is used to build the final container image | ||||
| FROM scratch | ||||
| COPY /smtp2shoutrrr /usr/bin/smtp2shoutrrr | ||||
| ENTRYPOINT ["/usr/bin/smtp2shoutrrr"] | ||||
|  |  | |||
							
								
								
									
										2
									
								
								Makefile
									
										
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
										
									
									
									
								
							|  | @ -12,7 +12,6 @@ CGO_ENABLED := 0 | |||
| BUILDS_PATH := ./dist | ||||
| FROM_MAKEFILE := y | ||||
| 
 | ||||
| CONTAINER_RUNTIME := podman | ||||
| CONTAINERFILE_NAME := Containerfile | ||||
| CONTAINER_ALPINE_VERSION := 3.20 | ||||
| CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}" | ||||
|  | @ -31,7 +30,6 @@ export TEST_OPTIONS | |||
| export TEST_TIMEOUT | ||||
| export BUILDS_PATH | ||||
| 
 | ||||
| export CONTAINER_RUNTIME | ||||
| export CONTAINERFILE_NAME | ||||
| export CONTAINER_ALPINE_VERSION | ||||
| export CONTAINER_SOURCE_URL | ||||
|  |  | |||
|  | @ -24,6 +24,11 @@ Addresses = ["some@email.com"] | |||
| Target = "ntfy://ntfy.sh/my-ntfy-topic?tags=thing" | ||||
| 
 | ||||
| # Note: Repeat [[Recipients]] as needed | ||||
| 
 | ||||
| # Optional: Configure a catch-all recipient for unmatched email addresses | ||||
| [CatchAll] | ||||
| # Shoutrrr service to forward unmatched emails to | ||||
| Target = "ntfy://ntfy.sh/catch-all-topic?tags=unmatched" | ||||
| ``` | ||||
| 
 | ||||
| ### From releases | ||||
|  |  | |||
							
								
								
									
										142
									
								
								backend.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								backend.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,142 @@ | |||
| package smtp2shoutrrr | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"log/slog" | ||||
| 	"net/mail" | ||||
| 	"net/url" | ||||
| 	"slices" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/containrrr/shoutrrr" | ||||
| 	"github.com/emersion/go-sasl" | ||||
| 	"github.com/emersion/go-smtp" | ||||
| ) | ||||
| 
 | ||||
| type Backend struct { | ||||
| 	config *Config | ||||
| } | ||||
| 
 | ||||
| func (bkd *Backend) sendNotification(recipient ConfigRecipient, email ReceivedEmail) error { | ||||
| 	if recipient.Target == "" { | ||||
| 		slog.Warn("no target provided for recipient", slog.String("recipient", strings.Join(recipient.Addresses, ","))) | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	urlParams := url.Values{ | ||||
| 		"title": {email.Msg.Header.Get("Subject")}, | ||||
| 	} | ||||
| 
 | ||||
| 	destinationURL := recipient.GetTargetURL() | ||||
| 	destinationURL.RawQuery = urlParams.Encode() | ||||
| 
 | ||||
| 	body, err := email.Body() | ||||
| 	if err != nil { | ||||
| 		slog.Error("Error getting email body", slog.String("err", err.Error())) | ||||
| 		return fmt.Errorf("failed to get email body: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := shoutrrr.Send(destinationURL.String(), body); err != nil { | ||||
| 		slog.Error("Error sending message", slog.String("err", err.Error())) | ||||
| 		return fmt.Errorf("failed to send notification: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (bkd *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) { | ||||
| 	return &Session{ | ||||
| 		forwarderFunc: bkd.forwardEmail, | ||||
| 		config:        bkd.config, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| func (bkd *Backend) forwardEmail(email ReceivedEmail) error { | ||||
| 	slog.Info("forwading message", slog.String("to", strings.Join(email.Recipients, ","))) | ||||
| 
 | ||||
| 	// Try to match configured recipients first | ||||
| 	matched := false | ||||
| 	for _, r := range bkd.config.Recipients { | ||||
| 		for _, a := range email.Recipients { | ||||
| 			if slices.Contains(r.Addresses, a) { | ||||
| 				if err := bkd.sendNotification(r, email); err != nil { | ||||
| 					return err | ||||
| 				} | ||||
| 				matched = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 		if matched { | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// If no recipient matched and catch-all is configured, use it | ||||
| 	if !matched && bkd.config.CatchAll != nil { | ||||
| 		slog.Info("using catch-all recipient for unmatched email") | ||||
| 		if err := bkd.sendNotification(*bkd.config.CatchAll, email); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type Session struct { | ||||
| 	addresses []string | ||||
| 	body      string | ||||
| 
 | ||||
| 	config *Config | ||||
| 
 | ||||
| 	forwarderFunc func(ReceivedEmail) error | ||||
| } | ||||
| 
 | ||||
| func (s *Session) AuthMechanisms() []string { | ||||
| 	return []string{sasl.Plain} | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Auth(mech string) (sasl.Server, error) { | ||||
| 	return sasl.NewPlainServer(func(identity, username, password string) error { | ||||
| 		if username != s.config.Username && password != s.config.Password { | ||||
| 			return fmt.Errorf("invalid credentials") | ||||
| 		} | ||||
| 		return nil | ||||
| 	}), nil | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Mail(from string, opts *smtp.MailOptions) error { | ||||
| 	slog.Debug("Mail from", slog.String("from", from)) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { | ||||
| 	slog.Debug("Rcpt to", slog.String("to", to)) | ||||
| 	s.addresses = append(s.addresses, to) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Data(r io.Reader) error { | ||||
| 	msg, err := mail.ReadMessage(r) | ||||
| 	if err != nil { | ||||
| 		slog.Error("Error reading data", slog.String("err", err.Error())) | ||||
| 		return fmt.Errorf("Error reading data: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	slog.Info("Received email", slog.String("destination", strings.Join(s.addresses, ","))) | ||||
| 
 | ||||
| 	if err := s.forwarderFunc(ReceivedEmail{ | ||||
| 		Recipients: s.addresses, | ||||
| 		Msg:        msg, | ||||
| 	}); err != nil { | ||||
| 		slog.Error("Error forwarding email", slog.String("err", err.Error())) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Reset() {} | ||||
| 
 | ||||
| func (s *Session) Logout() error { | ||||
| 	return nil | ||||
| } | ||||
|  | @ -1,236 +1,16 @@ | |||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"log" | ||||
| 	"log/slog" | ||||
| 	"mime" | ||||
| 	"mime/multipart" | ||||
| 	"net/mail" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"slices" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"git.nakama.town/fmartingr/gotoolkit/encoding" | ||||
| 	"git.nakama.town/fmartingr/gotoolkit/model" | ||||
| 	"git.nakama.town/fmartingr/gotoolkit/service" | ||||
| 	"github.com/containrrr/shoutrrr" | ||||
| 	"github.com/emersion/go-sasl" | ||||
| 	"github.com/emersion/go-smtp" | ||||
| 
 | ||||
| 	"git.nakama.town/fmartingr/smtp2shoutrrr" | ||||
| ) | ||||
| 
 | ||||
| type ReceivedEmail struct { | ||||
| 	Recipients []string | ||||
| 	Msg        *mail.Message | ||||
| 	body       string | ||||
| } | ||||
| 
 | ||||
| func (re *ReceivedEmail) Body() (string, error) { | ||||
| 	if re.body == "" { | ||||
| 		// Get the Content-Type header | ||||
| 		contentType := re.Msg.Header.Get("Content-Type") | ||||
| 
 | ||||
| 		if contentType == "" { | ||||
| 			body, err := io.ReadAll(re.Msg.Body) | ||||
| 			if err != nil { | ||||
| 				return "", fmt.Errorf("failed to read email body: %w", err) | ||||
| 			} | ||||
| 			re.body = string(body) | ||||
| 		} else { | ||||
| 			mediaType, params, err := mime.ParseMediaType(contentType) | ||||
| 			if err != nil { | ||||
| 				log.Fatalf("Failed to parse Content-Type: %v", err) | ||||
| 			} | ||||
| 
 | ||||
| 			if strings.HasPrefix(mediaType, "multipart/alternative") { | ||||
| 				// Parse the multipart message | ||||
| 				mr := multipart.NewReader(re.Msg.Body, params["boundary"]) | ||||
| 				for { | ||||
| 					part, err := mr.NextPart() | ||||
| 					if err != nil { | ||||
| 						break // End of parts | ||||
| 					} | ||||
| 					defer part.Close() | ||||
| 
 | ||||
| 					// Print part headers | ||||
| 					fmt.Printf("Part Content-Type: %s\n", part.Header.Get("Content-Type")) | ||||
| 
 | ||||
| 					// Set body if this is the text/plain part | ||||
| 					if strings.HasPrefix(part.Header.Get("Content-Type"), "text/plain") { | ||||
| 						// Read the part's body | ||||
| 						body := new(bytes.Buffer) | ||||
| 						_, err = body.ReadFrom(part) | ||||
| 						if err != nil { | ||||
| 							slog.Error("Failed to read part body", slog.String("err", err.Error())) | ||||
| 							return "", fmt.Errorf("failed to read part body: %w", err) | ||||
| 						} | ||||
| 
 | ||||
| 						re.body = body.String() | ||||
| 						break | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return re.body, nil | ||||
| } | ||||
| 
 | ||||
| var _ model.Server = (*smtpServer)(nil) | ||||
| 
 | ||||
| type smtpServer struct { | ||||
| 	backend *smtp.Server | ||||
| 	config  smtp2shoutrrr.Config | ||||
| } | ||||
| 
 | ||||
| func (s *smtpServer) IsEnabled() bool { | ||||
| 	return true | ||||
| } | ||||
| 
 | ||||
| func (s *smtpServer) Start(_ context.Context) error { | ||||
| 	slog.Info("Started SMTP server", slog.String("addr", s.backend.Addr)) | ||||
| 	return s.backend.ListenAndServe() | ||||
| } | ||||
| 
 | ||||
| func (s *smtpServer) Stop(ctx context.Context) error { | ||||
| 	slog.Info("Stopping SMTP server") | ||||
| 	return s.backend.Shutdown(ctx) | ||||
| } | ||||
| 
 | ||||
| func NewSMTPServer(config *smtp2shoutrrr.Config) model.Server { | ||||
| 	be := &Backend{ | ||||
| 		config: config, | ||||
| 	} | ||||
| 
 | ||||
| 	smtpBackend := smtp.NewServer(be) | ||||
| 	smtpBackend.Addr = fmt.Sprintf(":%d", config.Port) | ||||
| 	// smtpBackend.Domain = "localhost" | ||||
| 	smtpBackend.WriteTimeout = 10 * time.Second | ||||
| 	smtpBackend.ReadTimeout = 10 * time.Second | ||||
| 	smtpBackend.MaxMessageBytes = 1024 * 1024 | ||||
| 	smtpBackend.MaxRecipients = 50 | ||||
| 	smtpBackend.AllowInsecureAuth = true | ||||
| 
 | ||||
| 	return &smtpServer{ | ||||
| 		backend: smtpBackend, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // The Backend implements SMTP server methods. | ||||
| type Backend struct { | ||||
| 	config *smtp2shoutrrr.Config | ||||
| } | ||||
| 
 | ||||
| // NewSession is called after client greeting (EHLO, HELO). | ||||
| func (bkd *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) { | ||||
| 	return &Session{ | ||||
| 		forwarderFunc: bkd.forwardEmail, | ||||
| 		config:        bkd.config, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| func (bkd *Backend) forwardEmail(email ReceivedEmail) error { | ||||
| 	slog.Info("forwading message", slog.String("to", strings.Join(email.Recipients, ","))) | ||||
| 
 | ||||
| 	for _, r := range bkd.config.Recipients { | ||||
| 		for _, a := range email.Recipients { | ||||
| 			if slices.Contains(r.Addresses, a) { | ||||
| 				urlParams := url.Values{ | ||||
| 					"title": {email.Msg.Header.Get("Subject")}, | ||||
| 				} | ||||
| 
 | ||||
| 				destinationURL := r.GetTargetURL() | ||||
| 				destinationURL.RawQuery = urlParams.Encode() | ||||
| 
 | ||||
| 				body, err := email.Body() | ||||
| 				if err != nil { | ||||
| 					slog.Error("Error getting email body", slog.String("err", err.Error())) | ||||
| 					continue | ||||
| 				} | ||||
| 
 | ||||
| 				if err := shoutrrr.Send(destinationURL.String(), body); err != nil { | ||||
| 					slog.Error("Error sending message", slog.String("err", err.Error())) | ||||
| 					continue | ||||
| 				} | ||||
| 
 | ||||
| 				// Already sent via this configuration, go to the next | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // A Session is returned after successful login. | ||||
| type Session struct { | ||||
| 	addresses []string | ||||
| 	body      string | ||||
| 
 | ||||
| 	config *smtp2shoutrrr.Config | ||||
| 
 | ||||
| 	forwarderFunc func(ReceivedEmail) error | ||||
| } | ||||
| 
 | ||||
| // AuthMechanisms returns a slice of available auth mechanisms; only PLAIN is | ||||
| // supported in this example. | ||||
| func (s *Session) AuthMechanisms() []string { | ||||
| 	return []string{sasl.Plain} | ||||
| } | ||||
| 
 | ||||
| // Auth is the handler for supported authenticators. | ||||
| func (s *Session) Auth(mech string) (sasl.Server, error) { | ||||
| 	return sasl.NewPlainServer(func(identity, username, password string) error { | ||||
| 		if username != s.config.Username && password != s.config.Password { | ||||
| 			return fmt.Errorf("invalid credentials") | ||||
| 		} | ||||
| 		return nil | ||||
| 	}), nil | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Mail(from string, opts *smtp.MailOptions) error { | ||||
| 	slog.Debug("Mail from", slog.String("from", from)) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { | ||||
| 	slog.Debug("Rcpt to", slog.String("to", to)) | ||||
| 	s.addresses = append(s.addresses, to) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Data(r io.Reader) error { | ||||
| 	msg, err := mail.ReadMessage(r) | ||||
| 	if err != nil { | ||||
| 		slog.Error("Error reading data", slog.String("err", err.Error())) | ||||
| 		return fmt.Errorf("Error reading data: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	slog.Info("Received email", slog.String("destination", strings.Join(s.addresses, ","))) | ||||
| 
 | ||||
| 	if err := s.forwarderFunc(ReceivedEmail{ | ||||
| 		Recipients: s.addresses, | ||||
| 		Msg:        msg, | ||||
| 	}); err != nil { | ||||
| 		slog.Error("Error forwarding email", slog.String("err", err.Error())) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Session) Reset() {} | ||||
| 
 | ||||
| func (s *Session) Logout() error { | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func main() { | ||||
| 	var config *smtp2shoutrrr.Config | ||||
| 	configPath := "config.toml" | ||||
|  | @ -252,7 +32,7 @@ func main() { | |||
| 	slog.Info("config loaded", slog.Int("recipients", len(config.Recipients))) | ||||
| 
 | ||||
| 	ctx := context.Background() | ||||
| 	smtpServer := NewSMTPServer(config) | ||||
| 	smtpServer := smtp2shoutrrr.NewSMTPServer(config) | ||||
| 
 | ||||
| 	svc, err := service.NewService([]model.Server{ | ||||
| 		smtpServer, | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ type Config struct { | |||
| 	Username   string | ||||
| 	Password   string | ||||
| 	Recipients []ConfigRecipient | ||||
| 	CatchAll   *ConfigRecipient | ||||
| } | ||||
| 
 | ||||
| func (c *Config) SetDefaults() { | ||||
|  |  | |||
							
								
								
									
										65
									
								
								email.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								email.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,65 @@ | |||
| package smtp2shoutrrr | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"log" | ||||
| 	"log/slog" | ||||
| 	"mime" | ||||
| 	"mime/multipart" | ||||
| 	"net/mail" | ||||
| 	"strings" | ||||
| ) | ||||
| 
 | ||||
| type ReceivedEmail struct { | ||||
| 	Recipients []string | ||||
| 	Msg        *mail.Message | ||||
| 	body       string | ||||
| } | ||||
| 
 | ||||
| func (re *ReceivedEmail) Body() (string, error) { | ||||
| 	if re.body == "" { | ||||
| 		contentType := re.Msg.Header.Get("Content-Type") | ||||
| 
 | ||||
| 		if contentType == "" { | ||||
| 			body, err := io.ReadAll(re.Msg.Body) | ||||
| 			if err != nil { | ||||
| 				return "", fmt.Errorf("failed to read email body: %w", err) | ||||
| 			} | ||||
| 			re.body = string(body) | ||||
| 		} else { | ||||
| 			mediaType, params, err := mime.ParseMediaType(contentType) | ||||
| 			if err != nil { | ||||
| 				log.Fatalf("Failed to parse Content-Type: %v", err) | ||||
| 			} | ||||
| 
 | ||||
| 			if strings.HasPrefix(mediaType, "multipart/alternative") { | ||||
| 				mr := multipart.NewReader(re.Msg.Body, params["boundary"]) | ||||
| 				for { | ||||
| 					part, err := mr.NextPart() | ||||
| 					if err != nil { | ||||
| 						break | ||||
| 					} | ||||
| 					defer part.Close() | ||||
| 
 | ||||
| 					fmt.Printf("Part Content-Type: %s\n", part.Header.Get("Content-Type")) | ||||
| 
 | ||||
| 					if strings.HasPrefix(part.Header.Get("Content-Type"), "text/plain") { | ||||
| 						body := new(bytes.Buffer) | ||||
| 						_, err = body.ReadFrom(part) | ||||
| 						if err != nil { | ||||
| 							slog.Error("Failed to read part body", slog.String("err", err.Error())) | ||||
| 							return "", fmt.Errorf("failed to read part body: %w", err) | ||||
| 						} | ||||
| 
 | ||||
| 						re.body = body.String() | ||||
| 						break | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return re.body, nil | ||||
| } | ||||
							
								
								
									
										50
									
								
								server.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								server.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | |||
| package smtp2shoutrrr | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"log/slog" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"git.nakama.town/fmartingr/gotoolkit/model" | ||||
| 	"github.com/emersion/go-smtp" | ||||
| ) | ||||
| 
 | ||||
| var _ model.Server = (*smtpServer)(nil) | ||||
| 
 | ||||
| type smtpServer struct { | ||||
| 	backend *smtp.Server | ||||
| 	config  Config | ||||
| } | ||||
| 
 | ||||
| func (s *smtpServer) IsEnabled() bool { | ||||
| 	return true | ||||
| } | ||||
| 
 | ||||
| func (s *smtpServer) Start(_ context.Context) error { | ||||
| 	slog.Info("Started SMTP server", slog.String("addr", s.backend.Addr)) | ||||
| 	return s.backend.ListenAndServe() | ||||
| } | ||||
| 
 | ||||
| func (s *smtpServer) Stop(ctx context.Context) error { | ||||
| 	slog.Info("Stopping SMTP server") | ||||
| 	return s.backend.Shutdown(ctx) | ||||
| } | ||||
| 
 | ||||
| func NewSMTPServer(config *Config) model.Server { | ||||
| 	be := &Backend{ | ||||
| 		config: config, | ||||
| 	} | ||||
| 
 | ||||
| 	smtpBackend := smtp.NewServer(be) | ||||
| 	smtpBackend.Addr = fmt.Sprintf(":%d", config.Port) | ||||
| 	smtpBackend.WriteTimeout = 10 * time.Second | ||||
| 	smtpBackend.ReadTimeout = 10 * time.Second | ||||
| 	smtpBackend.MaxMessageBytes = 1024 * 1024 | ||||
| 	smtpBackend.MaxRecipients = 50 | ||||
| 	smtpBackend.AllowInsecureAuth = true | ||||
| 
 | ||||
| 	return &smtpServer{ | ||||
| 		backend: smtpBackend, | ||||
| 	} | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue