Refactor API#67
Conversation
Go coverage report (utils) |
Go coverage report (api) |
Go coverage report (webserver) |
There was a problem hiding this comment.
Pull request overview
This PR refactors the API from a single main file into focused internal packages (app wiring, HTTP routing/middleware, resources/schemas, stats, and contact hooks), and updates the build target accordingly.
Changes:
- Split the prior monolithic API implementation into
internal/app,internal/httpapi,internal/resources,internal/stats, andinternal/contact. - Introduce dedicated stats parsing/recording and contact-request insert hooks with new unit tests.
- Update
api/Makefileto build a command-based entrypoint (intended under./cmd/api) and remove the oldafn-rest.gomain program.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| api/Makefile | Changes build command to output api binary and target ./cmd/api. |
| api/internal/stats/service.go | Adds stats payload parsing + metrics recording helpers. |
| api/internal/stats/service_test.go | Unit tests for stats payload parsing and metric recording. |
| api/internal/resources/schema.go | Defines REST-layer schemas and an email validator. |
| api/internal/resources/index.go | Builds/binds the REST-layer resource index and attaches contact hooks. |
| api/internal/httpapi/router.go | Adds top-level router that bypasses auth for stats and applies CORS. |
| api/internal/httpapi/router_test.go | Tests routing behavior (stats bypass, auth required, CORS headers). |
| api/internal/httpapi/middleware_cors.go | Adds simple CORS header middleware. |
| api/internal/httpapi/middleware_auth.go | Adds “open resource” rules + Basic Auth checker. |
| api/internal/httpapi/middleware_auth_test.go | Unit tests for auth/open-resource behavior. |
| api/internal/httpapi/handler_stats.go | Adds HTTP stats handler delegating to stats service. |
| api/internal/httpapi/handler_stats_test.go | Unit tests for stats handler responses and error mapping. |
| api/internal/contact/service.go | Adds contact request throttling + email sending hooks. |
| api/internal/contact/service_test.go | Unit tests for throttling and email hook behavior. |
| api/internal/app/run.go | Wires services/resources/router and starts the HTTP server. |
| api/internal/app/config.go | Introduces config struct + env loader. |
| api/afn-rest.go | Removes old monolithic main implementation. |
| api/afn-rest_test.go | Removes old tests tied to the monolithic main implementation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| api: | ||
| CGO_ENABLED=0 go build -v | ||
|
|
||
| CGO_ENABLED=0 go build -v -o api ./cmd/api |
There was a problem hiding this comment.
The Makefile now builds ./cmd/api, but there is no api/cmd/api directory (and no package main under api/), so make api will fail. Either add the missing cmd/api entrypoint (main package) or update the build target to the actual main package path.
| decoder := json.NewDecoder(bytes.NewBuffer(body)) | ||
| if err := decoder.Decode(&payload); err != nil { | ||
| return nil, ErrInvalidJSON | ||
| } |
There was a problem hiding this comment.
ParsePayload can panic when the request body is valid JSON null: decoding into a map[string]string results in a nil map, and then payload["ip"] = ... will cause an assignment-to-nil-map panic. Consider initializing payload when nil (or rejecting non-object payloads explicitly) before writing the ip field.
| } | |
| } | |
| if payload == nil { | |
| payload = make(map[string]string) | |
| } |
| ipKey := strings.ReplaceAll(payload["ip"], ".", "_") | ||
| s.metrics.Increment("afn.stats-hits." + ipKey) | ||
|
|
||
| if payload["type"] == "increment" { | ||
| s.metrics.Increment(payload["key"]) | ||
| } |
There was a problem hiding this comment.
extractIP can return IPv6 addresses (e.g. "::1"), but Record only replaces '.' with '_' when building the statsd bucket name. A ':' in the metric name will break the statsd line protocol (colon is a reserved separator). Normalize IPv6 too (e.g., replace ':' as well, or hash/encode the IP) before calling Increment.
| var pathStatsRE = regexp.MustCompile(`^/stats`) | ||
|
|
||
| func NewRouter(apiHandler http.Handler, statsHandler http.Handler, username string, password string) http.Handler { | ||
| handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| if pathStatsRE.MatchString(r.URL.Path) { | ||
| statsHandler.ServeHTTP(w, r) | ||
| return |
There was a problem hiding this comment.
The stats route match ^/stats will also match paths like /statsanything, which will bypass auth and be routed to the stats handler. If the intent is only to expose /stats (and maybe /stats/...), tighten the matcher (e.g., ^/stats(?:/|$)) to avoid accidentally opening other endpoints.
|
|
||
| func Authenticate(w http.ResponseWriter, r *http.Request, username string, password string) bool { | ||
| requestUsername, requestPassword, ok := r.BasicAuth() | ||
| if !ok || requestUsername != username || requestPassword != password { |
There was a problem hiding this comment.
Authenticate returns a 401 without setting WWW-Authenticate. For HTTP Basic Auth, clients (especially browsers) rely on WWW-Authenticate: Basic realm=... to prompt for credentials; without it, auth UX can break. Consider adding that header on unauthorized responses.
| if !ok || requestUsername != username || requestPassword != password { | |
| if !ok || requestUsername != username || requestPassword != password { | |
| w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`) |
| var emailRegex = regexp.MustCompile(`\S+@\S+`) | ||
|
|
||
| func MimeTypeSchema() schema.Schema { | ||
| return schema.Schema{ | ||
| Description: "The mime_type object", | ||
| Fields: schema.Fields{ | ||
| "id": schema.IDField, |
There was a problem hiding this comment.
The email validation regex is not anchored (\S+@\S+), so it will accept strings that merely contain an email substring (including values with CRLF/header injection appended). Since contact_email is later written into mail headers (Reply-To:), this is a security risk. Use a stricter/anchored check (e.g. ^\S+@\S+$) or prefer net/mail address parsing and explicitly reject control characters.
| type emailValidator struct{} | ||
|
|
||
| func (emailValidator) Validate(value interface{}) (interface{}, error) { | ||
| email, ok := value.(string) | ||
| if !ok { | ||
| return value, errors.New("Invalid email format") | ||
| } | ||
| if !emailRegex.MatchString(email) { | ||
| return email, errors.New("Invalid email format") | ||
| } |
There was a problem hiding this comment.
The new emailValidator behavior (type assertion + regex validation) no longer has direct unit test coverage (the previous TestEmailValidator was deleted with the old main package). Consider adding a resources package test that asserts valid emails pass and invalid/malicious inputs (e.g. strings with whitespace/CRLF) are rejected, to prevent regressions.
| statsConn, _ := statsd.New(statsd.Address(cfg.StatsdAddress)) | ||
| if statsConn != nil { | ||
| defer statsConn.Close() | ||
| } | ||
|
|
||
| statsService := stats.NewService(statsConn) |
There was a problem hiding this comment.
statsd.New(...) error is ignored. If statsd connection/setup fails, the service will silently run without metrics, which is hard to diagnose. Consider handling/logging the returned error and/or making metrics optional explicitly via config.
| statsService := stats.NewService(statsConn) | ||
| contactCache := cache.New(24*60*60*1000000000, 60*1000000000) | ||
| contactService := contact.NewService(contactCache, cfg.MaxContactRequestsPerDay, cfg.SupportEmail, utils.SendEmail) | ||
|
|
There was a problem hiding this comment.
cache.New(24*60*60*1000000000, 60*1000000000) hard-codes nanosecond math, which is easy to misread and error-prone. Prefer time.Hour, time.Minute, etc. (e.g., 24*time.Hour and time.Minute) for clarity and maintainability.
| return Config{ | ||
| DataDir: dataDir, | ||
| ListenAddr: listenAddr, | ||
| Username: os.Getenv("AFN_REST_USERNAME"), | ||
| Password: os.Getenv("AFN_REST_PASSWORD"), | ||
| SupportEmail: os.Getenv("AFN_SUPPORT_EMAIL"), | ||
| StatsdAddress: os.Getenv("AFN_STATSD_URI"), | ||
| MaxContactRequestsPerDay: defaultContactRequestsPerDay, | ||
| } |
There was a problem hiding this comment.
MaxContactRequestsPerDay is present in Config, but LoadConfigFromEnv always sets it to the default constant and provides no way to override it via env/config. Either remove the field (if not meant to be configurable) or load it from an environment variable (with parsing + validation) so the config struct reflects actual behavior.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @if [ -d ./cmd/api ]; then \ | ||
| CGO_ENABLED=0 go build -v -o api ./cmd/api; \ | ||
| elif [ -d ./api/cmd/api ]; then \ | ||
| CGO_ENABLED=0 go build -v -o api ./api/cmd/api; \ | ||
| else \ | ||
| echo "cannot find api main package (expected ./cmd/api or ./api/cmd/api)"; \ | ||
| exit 1; \ |
There was a problem hiding this comment.
The build logic checks for ./cmd/api and ./api/cmd/api, but neither directory exists in this repo (there is no Go main package under api/ right now). As written, make api will always hit the error branch and fail; either add the expected cmd entrypoint(s) or update the Makefile to build the actual main package path/output binary name.
Also note the root Makefile runs cd api && make afn-rest-32 / afn-rest-64; those targets no longer exist in api/Makefile, so make all-golang will break unless those targets are restored or the root Makefile is updated accordingly.
| ipKey := strings.NewReplacer(".", "_", ":", "_").Replace(payload["ip"]) | ||
| s.metrics.Increment("afn.stats-hits." + ipKey) | ||
|
|
||
| if payload["type"] == "increment" { | ||
| s.metrics.Increment(payload["key"]) | ||
| } |
There was a problem hiding this comment.
Record increments a metric name taken directly from the client-provided payload (payload["key"]). Because the stats endpoint is unauthenticated, this allows unbounded/high-cardinality metric creation (and potentially invalid statsd bucket names), which can overload your metrics backend. Consider validating the key against an allowlist/prefix + length/charset constraints before calling Increment, or map known logical events to fixed metric names server-side.
| log.Printf("Stats request from %s", payload["ip"]) | ||
| if payload["type"] == "increment" { | ||
| log.Printf("afn.stats-hits.%s from %s", payload["key"], payload["ip"]) |
There was a problem hiding this comment.
These log lines interpolate payload["ip"] and payload["key"] directly using %s. Because both are client-controlled (and /stats bypasses auth), this enables log forging/injection (e.g., embedding newlines) and can generate extremely noisy logs. Consider logging with %q and/or sanitizing/validating these fields before logging (or downgrade/remove per-request logs for this endpoint).
| log.Printf("Stats request from %s", payload["ip"]) | |
| if payload["type"] == "increment" { | |
| log.Printf("afn.stats-hits.%s from %s", payload["key"], payload["ip"]) | |
| log.Printf("Stats request from %q", payload["ip"]) | |
| if payload["type"] == "increment" { | |
| log.Printf("afn.stats-hits.%q from %q", payload["key"], payload["ip"]) |
| func Authenticate(w http.ResponseWriter, r *http.Request, username string, password string) bool { | ||
| requestUsername, requestPassword, ok := r.BasicAuth() | ||
| if !ok || requestUsername != username || requestPassword != password { | ||
| w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`) | ||
| w.WriteHeader(http.StatusUnauthorized) | ||
| _, _ = w.Write([]byte("Unauthorized")) | ||
| return false |
There was a problem hiding this comment.
Authenticate compares Basic Auth credentials using normal string equality. If these endpoints are exposed beyond fully trusted networks, prefer constant-time comparison (e.g., subtle.ConstantTimeCompare on []byte) to reduce timing side-channels, and consider explicitly rejecting empty configured credentials to avoid accidentally running with a blank username/password.
| log.Printf("Unable to build contact request email: %v", buildErr) | ||
| continue | ||
| } | ||
| _ = s.sendEmail(recipients, msg) |
There was a problem hiding this comment.
AfterInsert ignores errors returned by sendEmail. If SMTP/transport fails, this will silently drop notifications and make incidents harder to debug. Consider capturing the returned error and logging it (and/or exposing it via metrics), even if you intentionally don't want to fail the request after persistence succeeds.
| _ = s.sendEmail(recipients, msg) | |
| if sendErr := s.sendEmail(recipients, msg); sendErr != nil { | |
| log.Printf("Unable to send contact request email: %v", sendErr) | |
| } |
| func (emailValidator) Validate(value interface{}) (interface{}, error) { | ||
| email, ok := value.(string) | ||
| if !ok { | ||
| return value, errors.New("Invalid email format") | ||
| } | ||
| if strings.ContainsAny(email, "\r\n") { | ||
| return email, errors.New("Invalid email format") | ||
| } | ||
| addr, err := mail.ParseAddress(email) | ||
| if err != nil || addr.Address != email { | ||
| return email, errors.New("Invalid email format") | ||
| } |
There was a problem hiding this comment.
The validator returns an error string starting with a capital letter ("Invalid email format"). In Go, error strings are typically lower-case and without punctuation; also, keeping the exact message stable matters if it is surfaced in API responses. Consider switching to a lower-case message (e.g., "invalid email format") and reusing a package-level sentinel error to avoid allocating the same string multiple times.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (s *Service) ParsePayload(r *http.Request) (map[string]string, error) { | ||
| body, err := io.ReadAll(r.Body) | ||
| if err != nil { | ||
| return nil, ErrInvalidPayload | ||
| } | ||
|
|
||
| var payload map[string]string | ||
| decoder := json.NewDecoder(bytes.NewBuffer(body)) | ||
| if err := decoder.Decode(&payload); err != nil { | ||
| return nil, ErrInvalidJSON | ||
| } |
There was a problem hiding this comment.
ParsePayload reads the entire request body with io.ReadAll(r.Body) without any size limit. This allows a client to send an extremely large body and force high memory usage (DoS). Consider enforcing a maximum payload size (e.g., via http.MaxBytesReader in the handler or io.LimitReader here) and returning a 413/400 when exceeded.
| func (s *Service) Record(payload map[string]string) { | ||
| if s.metrics == nil { | ||
| return | ||
| } | ||
|
|
||
| ipKey := strings.NewReplacer(".", "_", ":", "_").Replace(payload["ip"]) |
There was a problem hiding this comment.
Record uses payload["ip"] directly to construct a StatsD bucket name, but ip can come from X-Forwarded-For and may contain arbitrary characters/very long values. This can create invalid metric names and unbounded cardinality. Consider validating/normalizing the IP (e.g., net.ParseIP, stripping ports/brackets) and capping length before using it in a metric key (or falling back to a constant like "unknown").
| func (s *Service) Record(payload map[string]string) { | |
| if s.metrics == nil { | |
| return | |
| } | |
| ipKey := strings.NewReplacer(".", "_", ":", "_").Replace(payload["ip"]) | |
| func normalizeIPMetricKey(raw string) string { | |
| const unknownIPMetricKey = "unknown" | |
| candidate := strings.TrimSpace(raw) | |
| if candidate == "" { | |
| return unknownIPMetricKey | |
| } | |
| if host, _, err := net.SplitHostPort(candidate); err == nil { | |
| candidate = host | |
| } else { | |
| candidate = strings.Trim(candidate, "[]") | |
| } | |
| ip := net.ParseIP(candidate) | |
| if ip == nil { | |
| return unknownIPMetricKey | |
| } | |
| ipKey := strings.NewReplacer(".", "_", ":", "_").Replace(ip.String()) | |
| if ipKey == "" || len(ipKey) > 64 { | |
| return unknownIPMetricKey | |
| } | |
| return ipKey | |
| } | |
| func (s *Service) Record(payload map[string]string) { | |
| if s.metrics == nil { | |
| return | |
| } | |
| ipKey := normalizeIPMetricKey(payload["ip"]) |
| } | ||
|
|
||
| if r.Method == http.MethodGet { | ||
| log.Print("Allowing without authentication for namespace that don't modify resources") |
There was a problem hiding this comment.
The log message has a grammatical error: "namespace that don't modify resources" should be "namespace that doesn't modify resources".
| log.Print("Allowing without authentication for namespace that don't modify resources") | |
| log.Print("Allowing without authentication for namespace that doesn't modify resources") |
| } | ||
|
|
||
| if r.Method == http.MethodGet { | ||
| log.Print("Allowing without authentication for namespace that don't modify resources") |
There was a problem hiding this comment.
IsOpenResource logs on every GET request (the common case) which can produce very high log volume and noise in production. Consider removing this log line or gating it behind a debug/verbose flag so normal read traffic doesn't spam logs.
| log.Print("Allowing without authentication for namespace that don't modify resources") |
| var msgBytes bytes.Buffer | ||
| if err := messageTemplate.Execute(&msgBytes, struct { | ||
| Emails string | ||
| Message string | ||
| ReplyTo string | ||
| }{ | ||
| Emails: strings.Join(recipients, ";"), | ||
| Message: message, | ||
| ReplyTo: replyTo, | ||
| }); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return io.ReadAll(&msgBytes) | ||
| } |
There was a problem hiding this comment.
buildMessage writes the rendered template into a bytes.Buffer and then calls io.ReadAll(&msgBytes), which adds an unnecessary read/copy. You can return the buffer contents directly (e.g., msgBytes.Bytes() / msgBytes.String()) to reduce allocations and simplify the code.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| statsService := stats.NewService(statsConn) | ||
| contactCache := cache.New(24*time.Hour, time.Minute) |
There was a problem hiding this comment.
statsConn can be nil when statsd initialization fails, but passing a typed-nil *statsd.Client into the Metrics interface makes s.metrics != nil and can lead to a panic when Increment is called. Consider explicitly passing nil to stats.NewService when statsConn == nil (or wrap it in a no-op implementation).
| contactCache := cache.New(24*time.Hour, time.Minute) | |
| statsService := stats.NewService(nil) | |
| if statsConn != nil { | |
| statsService = stats.NewService(statsConn) | |
| } |
| "github.com/rs/rest-layer/resource" | ||
| ) | ||
|
|
||
| var errTooManyRequests = errors.New("Too many contact requests, try again later") |
There was a problem hiding this comment.
Go error strings are conventionally lowercase and without punctuation. Returning errors.New("Too many …") makes the message inconsistent with typical Go error style and couples tests/clients to that exact capitalization; consider using a lowercase message (and updating tests to assert via errors.Is).
| var errTooManyRequests = errors.New("Too many contact requests, try again later") | |
| var errTooManyRequests = errors.New("too many contact requests, try again later") |
| body, err := io.ReadAll(r.Body) | ||
| if err != nil { | ||
| return nil, ErrInvalidPayload | ||
| } | ||
|
|
There was a problem hiding this comment.
ParsePayload reads the entire request body into memory (io.ReadAll) and then decodes JSON from a new buffer, which adds an extra allocation and copy. Decode directly from r.Body (optionally with an io.LimitReader) to reduce memory use for large bodies.
9e4e563 to
2c2a5eb
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $.ajax({ | ||
| type: "POST", | ||
| url: "https://api.anyfile-notepad.semaan.ca/contact_requests", | ||
| url: "http://localhost:8001/contact_requests", |
There was a problem hiding this comment.
The contact form now posts to a hard-coded http://localhost:8001/contact_requests, which will break in non-local deployments and downgrades to plain HTTP. Consider using a relative URL (same-origin), or injecting the API base URL via configuration/build-time variable so production points to the correct HTTPS endpoint.
| url: "http://localhost:8001/contact_requests", | |
| url: "/contact_requests", |
| } | ||
|
|
||
| func (s *Service) BeforeInsert(_ context.Context, items []*resource.Item) error { | ||
| if s.cache != nil && s.cache.ItemCount() >= s.maxPerDay { |
There was a problem hiding this comment.
Rate limiting uses cache.ItemCount() >= s.maxPerDay without accounting for len(items) in a single insert batch. A request inserting multiple items can exceed the daily limit (e.g., 9 cached + 2 new with max=10). Consider checking cache.ItemCount()+len(items) > s.maxPerDay (or equivalent) before allowing the insert.
| if s.cache != nil && s.cache.ItemCount() >= s.maxPerDay { | |
| if s.cache != nil && s.cache.ItemCount()+len(items) > s.maxPerDay { |
| var auth smtp.Auth | ||
| if user != "" || password != "" { | ||
| auth = smtp.PlainAuth("", user, password, host) | ||
| } | ||
|
|
||
| skipTLSVerify, _ := strconv.ParseBool(os.Getenv("SMTP_SKIP_TLS_VERIFY")) | ||
|
|
There was a problem hiding this comment.
SMTP_SKIP_TLS_VERIFY is parsed directly with strconv.ParseBool(os.Getenv(...)), unlike other implementations in this repo that trim surrounding quotes (e.g. "'true'"). This makes behavior inconsistent and can silently ignore a quoted boolean. Consider applying the same trimming/normalization here (and adding a test for the quoted case).
| func sendEmailWithOptionalTLS(to []string, msg []byte) error { | ||
| host := os.Getenv("SMTP_HOST") | ||
| port := os.Getenv("SMTP_PORT") | ||
| from := os.Getenv("SMTP_FROM") | ||
| user := os.Getenv("SMTP_USER") | ||
| password := os.Getenv("SMTP_PASSWORD") | ||
| addr := net.JoinHostPort(host, port) |
There was a problem hiding this comment.
This SMTP implementation is effectively duplicated across webserver/email.go, api/internal/app/email.go, and utils/utils.go and has already started to diverge (e.g., boolean parsing behavior). Consider extracting the shared logic into a single package/function to avoid future inconsistencies and reduce maintenance overhead.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var auth smtp.Auth | ||
| if user != "" || password != "" { | ||
| auth = smtp.PlainAuth("", user, password, host) | ||
| } |
| rawSkipTLSVerify := strings.TrimSpace(os.Getenv("SMTP_SKIP_TLS_VERIFY")) | ||
| rawSkipTLSVerify = strings.Trim(rawSkipTLSVerify, "\"'") | ||
| skipTLSVerify, _ := strconv.ParseBool(rawSkipTLSVerify) | ||
|
|
||
| err := error(nil) | ||
| if skipTLSVerify { | ||
| err = smtpSendMailWithTLSConfig(addr, host, from, to, msg, auth, true) | ||
| } else { |
| if user != "" || password != "" { | ||
| auth = smtp.PlainAuth("", user, password, host) |
| skipTLSVerify, _ := strconv.ParseBool(rawSkipTLSVerify) | ||
|
|
||
| err := error(nil) | ||
| if skipTLSVerify { |
| func (s *Service) BeforeInsert(_ context.Context, items []*resource.Item) error { | ||
| if s.cache != nil && s.cache.ItemCount()+len(items) > s.maxPerDay { | ||
| return errTooManyRequests | ||
| } |
| if user != "" || password != "" { | ||
| auth = smtp.PlainAuth("", user, password, host) |
| rawSkipTLSVerify := strings.TrimSpace(os.Getenv("SMTP_SKIP_TLS_VERIFY")) | ||
| rawSkipTLSVerify = strings.Trim(rawSkipTLSVerify, "\"'") | ||
| skipTLSVerify, _ := strconv.ParseBool(rawSkipTLSVerify) | ||
|
|
||
| // Here we do it all: connect to our server, set up a message and send it | ||
| err := smtpSendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), auth, os.Getenv("SMTP_FROM"), to, msg) | ||
| err := error(nil) | ||
| if skipTLSVerify { | ||
| err = smtpSendMailWithTLSConfig(addr, host, from, to, msg, auth, true) |
No description provided.