From e6d15980cc511d9f25ed2b1fbb6270f655ee0ec1 Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 27 Sep 2026 17:44:00 +0300 Subject: [PATCH 01/11] refactor(web): job IDs from crypto/rand.Text, drop google/uuid --- go.mod | 2 +- internal/web/job.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 965e925..7e3f17b 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/emersion/go-imap v1.2.1 github.com/emersion/go-message v0.18.2 github.com/go-chi/chi/v5 v5.3.2 - github.com/google/uuid v1.6.0 github.com/spf13/cobra v1.10.2 golang.org/x/sys v0.47.0 gopkg.in/yaml.v3 v3.0.1 @@ -26,6 +25,7 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/internal/web/job.go b/internal/web/job.go index f8eabf4..dfe3f1b 100644 --- a/internal/web/job.go +++ b/internal/web/job.go @@ -2,6 +2,7 @@ package web import ( "context" + "crypto/rand" "encoding/json" "os" "path/filepath" @@ -9,7 +10,6 @@ import ( "time" "github.com/drumandbytes/eraser/internal/config" - "github.com/google/uuid" ) // JobStatus represents the status of a background job @@ -227,7 +227,7 @@ func (jm *JobManager) createLocked(total int, profileID string) *Job { ctx, cancel := context.WithCancel(context.Background()) job := &Job{ - ID: uuid.New().String(), + ID: rand.Text(), ProfileID: profileID, Status: JobStatusRunning, Progress: 0, From 7183c59b271116009820e9ded78a5d6ae3590a1f Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 27 Sep 2026 17:44:39 +0300 Subject: [PATCH 02/11] refactor(inbox): extract hrefs with x/net/html, drop goquery --- go.mod | 4 +--- go.sum | 4 ---- internal/inbox/parser.go | 44 +++++++++++++++++++++-------------- internal/inbox/parser_test.go | 15 ++++++++++++ 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index 7e3f17b..ab0df5e 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,18 @@ go 1.26 require ( filippo.io/csrf v0.2.1 - github.com/PuerkitoBio/goquery v1.13.0 github.com/chromedp/chromedp v0.16.0 github.com/emersion/go-imap v1.2.1 github.com/emersion/go-message v0.18.2 github.com/go-chi/chi/v5 v5.3.2 github.com/spf13/cobra v1.10.2 + golang.org/x/net v0.58.0 golang.org/x/sys v0.47.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.59.0 ) require ( - github.com/andybalholm/cascadia v1.3.4 // indirect github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -32,7 +31,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/mod v0.40.0 // indirect - golang.org/x/net v0.58.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect modernc.org/libc v1.75.7 // indirect diff --git a/go.sum b/go.sum index 30cde73..1e47472 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ filippo.io/csrf v0.2.1 h1:MdV/y9xOECwJko48lPkH9NaYNpZ6kYfaNlgnZk4k6Uo= filippo.io/csrf v0.2.1/go.mod h1:eVfdeENlqr/ErpNx4E5I6a11I1aP0WL/PPkzKD1d960= -github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM= -github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g= -github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= -github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32 h1:6JI+JS7Zef+bMzZQ+OgzTHf79v3GqdvP6rD0FaP9CMk= github.com/chromedp/cdproto v0.0.0-20260804232424-e85f50dbfd32/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0= github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= diff --git a/internal/inbox/parser.go b/internal/inbox/parser.go index f17508c..c9b18df 100644 --- a/internal/inbox/parser.go +++ b/internal/inbox/parser.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/PuerkitoBio/goquery" + "golang.org/x/net/html" ) // ExtractedURLs contains categorized URLs from an email @@ -159,25 +159,33 @@ func extractURLsFromText(text string) []string { // extractURLsFromHTML extracts href values. The size bound is at the MIME read // in monitor.go; html is already in memory here. -func extractURLsFromHTML(html string) []string { +func extractURLsFromHTML(doc string) []string { var urls []string - - doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) - if err != nil { - // Fallback to regex - return extractURLsFromText(html) - } - - doc.Find("a[href]").Each(func(i int, s *goquery.Selection) { - if href, exists := s.Attr("href"); exists { - urls = append(urls, href) + var text strings.Builder + z := html.NewTokenizer(strings.NewReader(doc)) + for { + switch z.Next() { + case html.ErrorToken: + // EOF, or unparseable input - either way, also check the text + // seen so far for bare URLs. + return append(urls, extractURLsFromText(text.String())...) + case html.TextToken: + text.Write(z.Text()) + text.WriteByte(' ') + case html.StartTagToken, html.SelfClosingTagToken: + name, hasAttr := z.TagName() + if string(name) != "a" { + continue + } + for hasAttr { + var key, val []byte + key, val, hasAttr = z.TagAttr() + if string(key) == "href" { + urls = append(urls, string(val)) + } + } } - }) - - // Also check for URLs in plain text within the HTML - urls = append(urls, extractURLsFromText(doc.Text())...) - - return urls + } } // cleanURL normalizes and validates a URL diff --git a/internal/inbox/parser_test.go b/internal/inbox/parser_test.go index 3a4b8d8..c866234 100644 --- a/internal/inbox/parser_test.go +++ b/internal/inbox/parser_test.go @@ -76,3 +76,18 @@ func TestIsPrivateOrLoopbackHost(t *testing.T) { }) } } + +func TestExtractURLsFromHTML(t *testing.T) { + doc := `

Confirm here + or visit https://acme.example/portal.

no href` + got := extractURLsFromHTML(doc) + want := []string{"https://acme.example/confirm?t=1&u=2", "https://acme.example/portal."} + if len(got) != len(want) { + t.Fatalf("got %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %q, want %q", got, want) + } + } +} From afb8c15f7607ab72594d24fc1645c92875cba4b0 Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 27 Sep 2026 17:45:46 +0300 Subject: [PATCH 03/11] fix(email): send a real Message-ID and record it The recorded MessageID was a local 'smtp--' string (and '%!d()' for web sends, which never set the sequence context value); the actual message had no Message-ID header, so the provider assigned one we never saw. Sends now carry and record that, and the email.SequenceKey context plumbing is gone. --- cmd/eraser/cmd_send.go | 3 +- internal/email/sender.go | 10 ------ internal/email/smtp.go | 13 ++++++- internal/email/smtp_test.go | 70 +++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/cmd/eraser/cmd_send.go b/cmd/eraser/cmd_send.go index 2700b47..bcbfbc3 100644 --- a/cmd/eraser/cmd_send.go +++ b/cmd/eraser/cmd_send.go @@ -255,8 +255,7 @@ func runSend() error { Body: emailMsg.Body, } - ctx := context.WithValue(context.Background(), email.SequenceKey, i) - result := sender.Send(ctx, msg) + result := sender.Send(context.Background(), msg) // Record in history record := &history.Record{ diff --git a/internal/email/sender.go b/internal/email/sender.go index 15d1206..d8313ae 100644 --- a/internal/email/sender.go +++ b/internal/email/sender.go @@ -8,16 +8,6 @@ import ( "github.com/drumandbytes/eraser/internal/config" ) -// ctxKey is an unexported type for context values defined by this package, -// so its keys can never collide with keys from another package using the -// same underlying string (see https://pkg.go.dev/context#WithValue). -type ctxKey string - -// SequenceKey is the context key runSend() uses to pass each broker's -// position in the current batch through to a Sender, so implementations -// (e.g. the SMTP sender) can fold it into a generated message ID. -const SequenceKey ctxKey = "sequence" - type Message struct { To string From string diff --git a/internal/email/smtp.go b/internal/email/smtp.go index 115fba7..c6c32e9 100644 --- a/internal/email/smtp.go +++ b/internal/email/smtp.go @@ -2,9 +2,11 @@ package email import ( "context" + "crypto/rand" "crypto/tls" "fmt" "net" + "net/mail" "net/smtp" "strings" @@ -31,7 +33,16 @@ func (s *SMTPSender) Send(ctx context.Context, msg Message) Result { addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) + // Our own Message-ID, so the one recorded in history is the one the + // broker actually receives (and quotes back in In-Reply-To). + domain := "localhost" + if from, err := mail.ParseAddress(msg.From); err == nil { + domain = from.Address[strings.LastIndex(from.Address, "@")+1:] + } + messageID := "<" + rand.Text() + "@" + domain + ">" + var message strings.Builder + fmt.Fprintf(&message, "Message-ID: %s\r\n", messageID) fmt.Fprintf(&message, "From: %s\r\n", msg.From) fmt.Fprintf(&message, "To: %s\r\n", msg.To) fmt.Fprintf(&message, "Subject: %s\r\n", msg.Subject) @@ -59,7 +70,7 @@ func (s *SMTPSender) Send(ctx context.Context, msg Message) Result { return Result{ Success: true, - MessageID: fmt.Sprintf("smtp-%s-%d", msg.To, ctx.Value(SequenceKey)), + MessageID: messageID, } } diff --git a/internal/email/smtp_test.go b/internal/email/smtp_test.go index f923b47..8ff5991 100644 --- a/internal/email/smtp_test.go +++ b/internal/email/smtp_test.go @@ -1,9 +1,11 @@ package email import ( + "bufio" "context" "net" "strconv" + "strings" "testing" "time" @@ -90,3 +92,71 @@ func splitHostPortForTest(t *testing.T, addr string) (string, int) { } return host, port } + +// recordingSMTPServer speaks just enough plaintext SMTP to accept one +// message and hands back its DATA. +func recordingSMTPServer(t *testing.T) (addr string, data <-chan string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + out := make(chan string, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + r := bufio.NewReader(conn) + reply := func(s string) { _, _ = conn.Write([]byte(s + "\r\n")) } + reply("220 test") + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + switch cmd := strings.ToUpper(strings.TrimSpace(line)); { + case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"): + reply("250 test") + case strings.HasPrefix(cmd, "DATA"): + reply("354 go ahead") + var b strings.Builder + for { + l, err := r.ReadString('\n') + if err != nil || l == ".\r\n" { + break + } + b.WriteString(l) + } + out <- b.String() + reply("250 ok") + case strings.HasPrefix(cmd, "QUIT"): + reply("221 bye") + return + default: + reply("250 ok") + } + } + }() + return ln.Addr().String(), out +} + +func TestSendRecordsTheMessageIDItSends(t *testing.T) { + addr, data := recordingSMTPServer(t) + host, portStr, _ := net.SplitHostPort(addr) + port, _ := strconv.Atoi(portStr) + s := NewSMTPSender(config.SMTPConfig{Host: host, Port: port}, "Jane Doe ") + + res := s.Send(context.Background(), Message{To: "privacy@acme.example", From: "Jane Doe ", Subject: "Erasure request", Body: "hi"}) + if !res.Success { + t.Fatalf("send failed: %v", res.Error) + } + if !strings.HasPrefix(res.MessageID, "<") || !strings.HasSuffix(res.MessageID, "@example.org>") { + t.Fatalf("MessageID %q isn't ", res.MessageID) + } + if got := <-data; !strings.Contains(got, "Message-ID: "+res.MessageID+"\r\n") { + t.Fatalf("sent message lacks header Message-ID: %s:\n%s", res.MessageID, got) + } +} From 18bca1d604ef17105a61517b59e4e10c5ecb61f8 Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 27 Sep 2026 17:45:53 +0300 Subject: [PATCH 04/11] refactor(web): drop RateLimiter cleanup goroutine (fixed key set) --- internal/web/server.go | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index a80ddb4..39683bd 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -40,6 +40,8 @@ const ( defaultSessionTTL = 30 * time.Minute ) +// RateLimiter caps requests per key in a sliding window. Keys are a fixed +// handful of endpoint names, so the map never needs pruning. type RateLimiter struct { mu sync.Mutex requests map[string][]time.Time @@ -53,7 +55,6 @@ func NewRateLimiter(limit int, window time.Duration) *RateLimiter { limit: limit, window: window, } - go rl.cleanupLoop() return rl } @@ -83,25 +84,6 @@ func (rl *RateLimiter) Allow(key string) bool { return true } -func (rl *RateLimiter) cleanupLoop() { - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - - for range ticker.C { - rl.mu.Lock() - windowStart := time.Now().Add(-rl.window) - for key, times := range rl.requests { - recent := rl.filterRecent(times, windowStart) - if len(recent) == 0 { - delete(rl.requests, key) - } else { - rl.requests[key] = recent - } - } - rl.mu.Unlock() - } -} - // Version is the build version shown in the web UI footer. main sets it from // its own -ldflags-injected version at startup; it stays "dev" otherwise. var Version = "dev" From 986c9047ff853335239b99b13be3bd147fb5ec8d Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 27 Sep 2026 17:46:39 +0300 Subject: [PATCH 05/11] refactor(history): one scanner and column list for broker responses --- internal/history/history.go | 221 ++++++++++-------------------------- 1 file changed, 63 insertions(+), 158 deletions(-) diff --git a/internal/history/history.go b/internal/history/history.go index cc3c0fd..f4726bc 100644 --- a/internal/history/history.go +++ b/internal/history/history.go @@ -730,59 +730,76 @@ func (s *Store) AddBrokerResponseIfNew(resp *BrokerResponse) (inserted bool, err return true, s.AddBrokerResponse(resp) } -func (s *Store) GetBrokerResponseByID(id int64, profileID string) (*BrokerResponse, error) { - query := `SELECT id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, email_body, - form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at - FROM broker_responses WHERE id = ? AND profile_id = ?` +// Column lists for scanBrokerResponse. The no-body variant is for listing +// pages, which don't show bodies and can hold hundreds of rows. +const ( + brokerResponseCols = `id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, email_body, + form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at` + brokerResponseColsNoBody = `id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, '' AS email_body, + form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at` +) +func scanBrokerResponse(scanner interface{ Scan(...any) error }) (*BrokerResponse, error) { var r BrokerResponse var needsReviewInt int - var receivedAtStr, processedAtStr, createdAtStr sql.NullString + var receivedAt, processedAt, createdAt sql.NullString var emailBody, formURL, confirmURL sql.NullString - if err := s.db.QueryRow(query, id, normalizeProfileID(profileID)).Scan( - &r.ID, &r.ProfileID, &r.BrokerID, &r.BrokerName, &r.ResponseType, &r.EmailFrom, &r.EmailSubject, &emailBody, - &formURL, &confirmURL, &r.Confidence, &needsReviewInt, &receivedAtStr, &processedAtStr, &createdAtStr); err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - return nil, fmt.Errorf("failed to get broker response: %w", err) + if err := scanner.Scan(&r.ID, &r.ProfileID, &r.BrokerID, &r.BrokerName, &r.ResponseType, &r.EmailFrom, &r.EmailSubject, &emailBody, + &formURL, &confirmURL, &r.Confidence, &needsReviewInt, &receivedAt, &processedAt, &createdAt); err != nil { + return nil, err } - r.EmailBody = emailBody.String r.FormURL = formURL.String r.ConfirmURL = confirmURL.String r.NeedsReview = needsReviewInt == 1 - r.ReceivedAt = parseFlexibleTimeString(receivedAtStr) - r.ProcessedAt = parseFlexibleTimeString(processedAtStr) - r.CreatedAt = parseFlexibleTimeString(createdAtStr) + r.ReceivedAt = parseFlexibleTimeString(receivedAt) + r.ProcessedAt = parseFlexibleTimeString(processedAt) + r.CreatedAt = parseFlexibleTimeString(createdAt) return &r, nil } -func (s *Store) FindBrokerResponseBySubject(profileID, brokerID, subject string) (*BrokerResponse, error) { - query := `SELECT id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, - form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at - FROM broker_responses WHERE profile_id = ? AND broker_id = ? AND email_subject = ? LIMIT 1` +func (s *Store) queryBrokerResponses(query string, args ...any) ([]BrokerResponse, error) { + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query broker responses: %w", err) + } + defer func() { _ = rows.Close() }() - var r BrokerResponse - var needsReviewInt int - var receivedAtStr, processedAtStr, createdAtStr sql.NullString - var formURL, confirmURL sql.NullString + var responses []BrokerResponse + for rows.Next() { + r, err := scanBrokerResponse(rows) + if err != nil { + return nil, fmt.Errorf("failed to scan broker response: %w", err) + } + responses = append(responses, *r) + } + return responses, rows.Err() +} - err := s.db.QueryRow(query, normalizeProfileID(profileID), brokerID, subject).Scan( - &r.ID, &r.ProfileID, &r.BrokerID, &r.BrokerName, &r.ResponseType, &r.EmailFrom, &r.EmailSubject, - &formURL, &confirmURL, &r.Confidence, &needsReviewInt, &receivedAtStr, &processedAtStr, &createdAtStr) - if err == sql.ErrNoRows { +func (s *Store) GetBrokerResponseByID(id int64, profileID string) (*BrokerResponse, error) { + r, err := scanBrokerResponse(s.db.QueryRow( + `SELECT `+brokerResponseCols+` FROM broker_responses WHERE id = ? AND profile_id = ?`, + id, normalizeProfileID(profileID))) + if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { - return nil, fmt.Errorf("failed to find broker response: %w", err) + return nil, fmt.Errorf("failed to get broker response: %w", err) } + return r, nil +} - r.FormURL = formURL.String - r.ConfirmURL = confirmURL.String - r.NeedsReview = needsReviewInt == 1 - - return &r, nil +func (s *Store) FindBrokerResponseBySubject(profileID, brokerID, subject string) (*BrokerResponse, error) { + r, err := scanBrokerResponse(s.db.QueryRow( + `SELECT `+brokerResponseColsNoBody+` FROM broker_responses WHERE profile_id = ? AND broker_id = ? AND email_subject = ? LIMIT 1`, + normalizeProfileID(profileID), brokerID, subject)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to find broker response: %w", err) + } + return r, nil } // UpdateBrokerResponseClassification updates the classification fields of a @@ -844,141 +861,29 @@ func (s *Store) ClearBrokerResponses() error { // (for reclassification - a full re-scan processes the whole shared inbox // regardless of which profile is active in the caller's session) func (s *Store) GetAllBrokerResponses() ([]BrokerResponse, error) { - query := `SELECT id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, email_body, - form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at - FROM broker_responses ORDER BY created_at DESC` - - rows, err := s.db.Query(query) - if err != nil { - return nil, fmt.Errorf("failed to query all broker responses: %w", err) - } - defer func() { _ = rows.Close() }() - - var responses []BrokerResponse - for rows.Next() { - var r BrokerResponse - var needsReviewInt int - var receivedAtStr, processedAtStr, createdAtStr sql.NullString - var formURL, confirmURL, emailBody sql.NullString - - err := rows.Scan(&r.ID, &r.ProfileID, &r.BrokerID, &r.BrokerName, &r.ResponseType, &r.EmailFrom, &r.EmailSubject, &emailBody, - &formURL, &confirmURL, &r.Confidence, &needsReviewInt, &receivedAtStr, &processedAtStr, &createdAtStr) - if err != nil { - return nil, fmt.Errorf("failed to scan broker response: %w", err) - } - - r.EmailBody = emailBody.String - r.FormURL = formURL.String - r.ConfirmURL = confirmURL.String - r.NeedsReview = needsReviewInt == 1 - - r.ReceivedAt = parseFlexibleTimeString(receivedAtStr) - r.ProcessedAt = parseFlexibleTimeString(processedAtStr) - r.CreatedAt = parseFlexibleTimeString(createdAtStr) - - responses = append(responses, r) - } - - return responses, rows.Err() + return s.queryBrokerResponses(`SELECT ` + brokerResponseCols + ` FROM broker_responses ORDER BY created_at DESC`) } // GetBrokerResponsesForExport returns one profile's responses, oldest first, // including email_body. Used by `eraser export`. func (s *Store) GetBrokerResponsesForExport(profileID string) ([]BrokerResponse, error) { - query := `SELECT id, profile_id, broker_id, broker_name, response_type, email_from, - email_subject, email_body, form_url, confirm_url, confidence, needs_review, - received_at, processed_at, created_at - FROM broker_responses WHERE profile_id = ? ORDER BY received_at ASC, id ASC` - - rows, err := s.db.Query(query, normalizeProfileID(profileID)) - if err != nil { - return nil, fmt.Errorf("failed to query broker responses: %w", err) - } - defer func() { _ = rows.Close() }() - - var responses []BrokerResponse - for rows.Next() { - var r BrokerResponse - var needsReviewInt int - var receivedAtStr, processedAtStr, createdAtStr sql.NullString - var formURL, confirmURL, emailBody sql.NullString - - if err := rows.Scan(&r.ID, &r.ProfileID, &r.BrokerID, &r.BrokerName, &r.ResponseType, - &r.EmailFrom, &r.EmailSubject, &emailBody, &formURL, &confirmURL, &r.Confidence, - &needsReviewInt, &receivedAtStr, &processedAtStr, &createdAtStr); err != nil { - return nil, fmt.Errorf("failed to scan broker response: %w", err) - } - r.EmailBody = emailBody.String - r.FormURL = formURL.String - r.ConfirmURL = confirmURL.String - r.NeedsReview = needsReviewInt == 1 - r.ReceivedAt = parseFlexibleTimeString(receivedAtStr) - r.ProcessedAt = parseFlexibleTimeString(processedAtStr) - r.CreatedAt = parseFlexibleTimeString(createdAtStr) - responses = append(responses, r) - } - return responses, rows.Err() + return s.queryBrokerResponses(`SELECT `+brokerResponseCols+` FROM broker_responses + WHERE profile_id = ? ORDER BY received_at ASC, id ASC`, normalizeProfileID(profileID)) } // GetBrokerResponses retrieves broker responses for one profile, with optional filtering func (s *Store) GetBrokerResponses(profileID, responseType string, needsReview bool, limit int) ([]BrokerResponse, error) { - var query string - var args []interface{} - profileID = normalizeProfileID(profileID) - - if responseType != "" && needsReview { - query = `SELECT id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, - form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at - FROM broker_responses WHERE profile_id = ? AND response_type = ? AND needs_review = 1 ORDER BY created_at DESC LIMIT ?` - args = []interface{}{profileID, responseType, limit} - } else if responseType != "" { - query = `SELECT id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, - form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at - FROM broker_responses WHERE profile_id = ? AND response_type = ? ORDER BY created_at DESC LIMIT ?` - args = []interface{}{profileID, responseType, limit} - } else if needsReview { - query = `SELECT id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, - form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at - FROM broker_responses WHERE profile_id = ? AND needs_review = 1 ORDER BY created_at DESC LIMIT ?` - args = []interface{}{profileID, limit} - } else { - query = `SELECT id, profile_id, broker_id, broker_name, response_type, email_from, email_subject, - form_url, confirm_url, confidence, needs_review, received_at, processed_at, created_at - FROM broker_responses WHERE profile_id = ? ORDER BY created_at DESC LIMIT ?` - args = []interface{}{profileID, limit} - } - - rows, err := s.db.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("failed to query broker responses: %w", err) + where := "profile_id = ?" + args := []any{normalizeProfileID(profileID)} + if responseType != "" { + where += " AND response_type = ?" + args = append(args, responseType) } - defer func() { _ = rows.Close() }() - - var responses []BrokerResponse - for rows.Next() { - var r BrokerResponse - var needsReviewInt int - var receivedAtStr, processedAtStr, createdAtStr sql.NullString - var formURL, confirmURL sql.NullString - - err := rows.Scan(&r.ID, &r.ProfileID, &r.BrokerID, &r.BrokerName, &r.ResponseType, &r.EmailFrom, &r.EmailSubject, - &formURL, &confirmURL, &r.Confidence, &needsReviewInt, &receivedAtStr, &processedAtStr, &createdAtStr) - if err != nil { - return nil, fmt.Errorf("failed to scan broker response: %w", err) - } - - r.FormURL = formURL.String - r.ConfirmURL = confirmURL.String - r.NeedsReview = needsReviewInt == 1 - - r.ReceivedAt = parseFlexibleTimeString(receivedAtStr) - r.ProcessedAt = parseFlexibleTimeString(processedAtStr) - r.CreatedAt = parseFlexibleTimeString(createdAtStr) - - responses = append(responses, r) + if needsReview { + where += " AND needs_review = 1" } - - return responses, rows.Err() + args = append(args, limit) + return s.queryBrokerResponses(`SELECT `+brokerResponseColsNoBody+` FROM broker_responses WHERE `+where+` ORDER BY created_at DESC LIMIT ?`, args...) } // GetResponseStats returns counts of response types for one profile From e11a3077a9d316ed57e9893599e21eb1e8388a13 Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 27 Sep 2026 17:50:02 +0300 Subject: [PATCH 06/11] refactor(browser): one CAPTCHA detection script instead of eight CaptchaInfo keeps only Found and Type; Confidence, FrameSrc, ElementID and the per-detector Description were never read (the description table overrode it). Same checks, same order - covered by a new Chrome fixture test per branch. --- internal/browser/browser_chrome_test.go | 35 ++ internal/browser/captcha.go | 474 +++--------------------- internal/browser/captcha_test.go | 8 +- 3 files changed, 91 insertions(+), 426 deletions(-) diff --git a/internal/browser/browser_chrome_test.go b/internal/browser/browser_chrome_test.go index f66d886..7c1c9ee 100644 --- a/internal/browser/browser_chrome_test.go +++ b/internal/browser/browser_chrome_test.go @@ -158,3 +158,38 @@ func TestSubmitForm_ClicksButtonByVisibleText(t *testing.T) { t.Errorf("document.title = %q after submitForm; want %q (button was not clicked by text match)", title, "clicked") } } + +// One page per detector branch, plus a clean page, so the single detection +// script keeps each check and its order. +func TestDetectCaptcha(t *testing.T) { + b := requireChrome(t) + defer b.Close() + + cases := []struct{ name, body, want string }{ + {"none", `
`, ""}, + {"recaptcha v2", `
`, CaptchaTypeRecaptchaV2}, + {"recaptcha v3", ``, CaptchaTypeRecaptchaV3}, + {"hcaptcha", `
`, CaptchaTypeHCaptcha}, + {"turnstile", `
`, CaptchaTypeTurnstile}, + {"funcaptcha", `
`, CaptchaTypeFunCaptcha}, + {"cloudflare", `Just a moment...

checking

`, CaptchaTypeCloudflare}, + {"text captcha", `

Enter the code shown

`, CaptchaTypeTextCaptcha}, + {"image captcha", `x`, CaptchaTypeImageCaptcha}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(b.ctx, b.config.Timeout) + defer cancel() + if err := chromedp.Run(ctx, chromedp.Navigate("data:text/html,"+tc.body+"")); err != nil { + t.Fatalf("navigate: %v", err) + } + got, err := b.detectCaptcha(ctx) + if err != nil { + t.Fatalf("detectCaptcha: %v", err) + } + if got.Type != tc.want || got.Found != (tc.want != "") { + t.Fatalf("got %+v, want type %q", got, tc.want) + } + }) + } +} diff --git a/internal/browser/captcha.go b/internal/browser/captcha.go index 72535bb..bf8b59a 100644 --- a/internal/browser/captcha.go +++ b/internal/browser/captcha.go @@ -6,17 +6,12 @@ import ( "github.com/chromedp/chromedp" ) -// CaptchaInfo contains information about detected CAPTCHAs +// CaptchaInfo is the result of detectCaptcha. type CaptchaInfo struct { - Found bool - Type string - FrameSrc string - ElementID string - Confidence float64 - Description string + Found bool + Type string // one of the CaptchaType constants } -// CaptchaType constants const ( CaptchaTypeRecaptchaV2 = "recaptcha_v2" CaptchaTypeRecaptchaV3 = "recaptcha_v3" @@ -29,413 +24,66 @@ const ( CaptchaTypeUnknown = "unknown" ) +// detectCaptchaJS returns the first CAPTCHA type found on the page, checked +// most specific first, or "" for none. +const detectCaptchaJS = `(function() { + var q = function(sel) { return document.querySelector(sel); }; + var body = document.body ? document.body.innerText.toLowerCase() : ''; + + if (q('iframe[src*="recaptcha"]') || q('.g-recaptcha, [data-sitekey]') || typeof grecaptcha !== 'undefined') return 'recaptcha_v2'; + var scripts = document.querySelectorAll('script[src*="recaptcha"]'); + for (var i = 0; i < scripts.length; i++) { + if (scripts[i].src.includes('render=')) return 'recaptcha_v3'; + } + if (q('script[src*="enterprise.js"]')) return 'recaptcha_v3'; + if (q('iframe[src*="hcaptcha"]') || q('.h-captcha, [data-hcaptcha-sitekey]') || typeof hcaptcha !== 'undefined') return 'hcaptcha'; + if (q('iframe[src*="challenges.cloudflare.com"]') || q('.cf-turnstile, [data-turnstile-sitekey]')) return 'cloudflare_turnstile'; + if (q('iframe[src*="funcaptcha"], iframe[src*="arkoselabs"]')) return 'funcaptcha'; + var fc = q('#FunCaptcha, [data-callback]'); + if (fc && fc.id && fc.id.toLowerCase().includes('funcaptcha')) return 'funcaptcha'; + + var title = document.title.toLowerCase(); + if (title.includes('just a moment') || title.includes('checking your browser') || q('form#challenge-form') || + q('.ray-id, [data-ray]') || (body.includes('ray id') && body.includes('cloudflare'))) return 'cloudflare_challenge'; + + var keywords = ['captcha', 'verification code', 'security code', 'enter the code', 'type the characters', + 'verify you are human', 'prove you are human', 'i am not a robot', 'human verification']; + var img = q('img[src*="captcha"], img[alt*="captcha"], .captcha-image'); + var input = q('input[name*="captcha"], input[id*="captcha"], input[placeholder*="captcha" i]'); + if ((img || input) && keywords.some(function(k) { return body.includes(k); })) return input ? 'text_captcha' : 'unknown'; + if (q('img[src*="captcha"], img[alt*="captcha"]')) return 'image_captcha'; + return ''; +})()` + // detectCaptcha errors only on context failure (browser died or timed out), // never for "no CAPTCHA". func (b *Browser) detectCaptcha(ctx context.Context) (CaptchaInfo, error) { - // Check for reCAPTCHA v2 - if result, err := detectRecaptchaV2(ctx); err != nil { - return CaptchaInfo{}, err - } else if result.Found { - return result, nil - } - - // Check for reCAPTCHA v3 (invisible) - if result, err := detectRecaptchaV3(ctx); err != nil { - return CaptchaInfo{}, err - } else if result.Found { - return result, nil - } - - // Check for hCaptcha - if result, err := detectHCaptcha(ctx); err != nil { - return CaptchaInfo{}, err - } else if result.Found { - return result, nil - } - - // Check for Cloudflare Turnstile - if result, err := detectTurnstile(ctx); err != nil { - return CaptchaInfo{}, err - } else if result.Found { - return result, nil - } - - // Check for FunCaptcha - if result, err := detectFunCaptcha(ctx); err != nil { - return CaptchaInfo{}, err - } else if result.Found { - return result, nil - } - - // Check for Cloudflare challenge page - if result, err := detectCloudflareChallenge(ctx); err != nil { - return CaptchaInfo{}, err - } else if result.Found { - return result, nil - } - - // Check for generic image/text CAPTCHA - if result, err := detectGenericCaptcha(ctx); err != nil { - return CaptchaInfo{}, err - } else if result.Found { - return result, nil - } - - return CaptchaInfo{}, nil -} - -// detectRecaptchaV2 checks for Google reCAPTCHA v2 -func detectRecaptchaV2(ctx context.Context) (CaptchaInfo, error) { - js := `(function() { - // Check for reCAPTCHA iframe - var iframe = document.querySelector('iframe[src*="recaptcha"]'); - if (iframe) { - return {found: true, src: iframe.src, type: 'iframe'}; - } - - // Check for reCAPTCHA div - var div = document.querySelector('.g-recaptcha, [data-sitekey]'); - if (div) { - return {found: true, id: div.id || '', type: 'div'}; - } - - // Check for grecaptcha object - if (typeof grecaptcha !== 'undefined') { - return {found: true, type: 'script'}; - } - - return {found: false}; - })()` - - var result map[string]interface{} - err := chromedp.Run(ctx, chromedp.Evaluate(js, &result)) - if err != nil { - if isContextErr(err) { - return CaptchaInfo{}, err - } - return CaptchaInfo{}, nil - } - - found, _ := result["found"].(bool) - if !found { - return CaptchaInfo{}, nil - } - - info := CaptchaInfo{ - Found: true, - Type: CaptchaTypeRecaptchaV2, - Confidence: 0.95, - Description: "Google reCAPTCHA v2 detected", - } - - if src, ok := result["src"].(string); ok { - info.FrameSrc = src - } - if id, ok := result["id"].(string); ok { - info.ElementID = id - } - - return info, nil -} - -// detectRecaptchaV3 checks for Google reCAPTCHA v3 (invisible) -func detectRecaptchaV3(ctx context.Context) (CaptchaInfo, error) { - js := `(function() { - // reCAPTCHA v3 is typically loaded via script - var scripts = document.querySelectorAll('script[src*="recaptcha"]'); - for (var i = 0; i < scripts.length; i++) { - if (scripts[i].src.includes('render=')) { - return {found: true, src: scripts[i].src}; - } - } - - // Check for enterprise version - scripts = document.querySelectorAll('script[src*="enterprise.js"]'); - if (scripts.length > 0) { - return {found: true, type: 'enterprise'}; - } - - return {found: false}; - })()` - - var result map[string]interface{} - err := chromedp.Run(ctx, chromedp.Evaluate(js, &result)) - if err != nil { - if isContextErr(err) { - return CaptchaInfo{}, err - } - return CaptchaInfo{}, nil - } - - found, _ := result["found"].(bool) - if !found { - return CaptchaInfo{}, nil - } - - return CaptchaInfo{ - Found: true, - Type: CaptchaTypeRecaptchaV3, - Confidence: 0.85, - Description: "Google reCAPTCHA v3 (invisible) detected - may not require interaction", - }, nil -} - -// detectHCaptcha checks for hCaptcha -func detectHCaptcha(ctx context.Context) (CaptchaInfo, error) { - js := `(function() { - // Check for hCaptcha iframe - var iframe = document.querySelector('iframe[src*="hcaptcha"]'); - if (iframe) { - return {found: true, src: iframe.src}; - } - - // Check for hCaptcha div - var div = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]'); - if (div) { - return {found: true, id: div.id || ''}; - } - - // Check for hcaptcha object - if (typeof hcaptcha !== 'undefined') { - return {found: true, type: 'script'}; - } - - return {found: false}; - })()` - - var result map[string]interface{} - err := chromedp.Run(ctx, chromedp.Evaluate(js, &result)) - if err != nil { - if isContextErr(err) { - return CaptchaInfo{}, err - } - return CaptchaInfo{}, nil - } - - found, _ := result["found"].(bool) - if !found { - return CaptchaInfo{}, nil - } - - info := CaptchaInfo{ - Found: true, - Type: CaptchaTypeHCaptcha, - Confidence: 0.95, - Description: "hCaptcha detected", - } - - if src, ok := result["src"].(string); ok { - info.FrameSrc = src - } - - return info, nil -} - -// detectTurnstile checks for Cloudflare Turnstile -func detectTurnstile(ctx context.Context) (CaptchaInfo, error) { - js := `(function() { - // Check for Turnstile iframe - var iframe = document.querySelector('iframe[src*="challenges.cloudflare.com"]'); - if (iframe) { - return {found: true, src: iframe.src}; - } - - // Check for Turnstile div - var div = document.querySelector('.cf-turnstile, [data-turnstile-sitekey]'); - if (div) { - return {found: true, id: div.id || ''}; - } - - return {found: false}; - })()` - - var result map[string]interface{} - err := chromedp.Run(ctx, chromedp.Evaluate(js, &result)) - if err != nil { - if isContextErr(err) { - return CaptchaInfo{}, err - } - return CaptchaInfo{}, nil - } - - found, _ := result["found"].(bool) - if !found { - return CaptchaInfo{}, nil - } - - return CaptchaInfo{ - Found: true, - Type: CaptchaTypeTurnstile, - Confidence: 0.95, - Description: "Cloudflare Turnstile detected", - }, nil -} - -// detectFunCaptcha checks for Arkose Labs FunCaptcha -func detectFunCaptcha(ctx context.Context) (CaptchaInfo, error) { - js := `(function() { - // Check for FunCaptcha iframe - var iframe = document.querySelector('iframe[src*="funcaptcha"], iframe[src*="arkoselabs"]'); - if (iframe) { - return {found: true, src: iframe.src}; - } - - // Check for FunCaptcha div - var div = document.querySelector('#FunCaptcha, [data-callback]'); - if (div && div.id && div.id.toLowerCase().includes('funcaptcha')) { - return {found: true, id: div.id}; - } - - return {found: false}; - })()` - - var result map[string]interface{} - err := chromedp.Run(ctx, chromedp.Evaluate(js, &result)) - if err != nil { - if isContextErr(err) { - return CaptchaInfo{}, err - } - return CaptchaInfo{}, nil - } - - found, _ := result["found"].(bool) - if !found { - return CaptchaInfo{}, nil - } - - return CaptchaInfo{ - Found: true, - Type: CaptchaTypeFunCaptcha, - Confidence: 0.90, - Description: "Arkose Labs FunCaptcha detected", - }, nil -} - -// detectCloudflareChallenge checks for Cloudflare challenge page -func detectCloudflareChallenge(ctx context.Context) (CaptchaInfo, error) { - js := `(function() { - // Check for Cloudflare challenge indicators - var title = document.title.toLowerCase(); - if (title.includes('just a moment') || title.includes('checking your browser')) { - return {found: true, type: 'title'}; - } - - // Check for challenge form - var form = document.querySelector('form#challenge-form'); - if (form) { - return {found: true, type: 'form'}; - } - - // Check for ray ID (Cloudflare identifier) - var rayId = document.querySelector('.ray-id, [data-ray]'); - var body = document.body.innerText.toLowerCase(); - if (rayId || (body.includes('ray id') && body.includes('cloudflare'))) { - return {found: true, type: 'rayid'}; - } - - return {found: false}; - })()` - - var result map[string]interface{} - err := chromedp.Run(ctx, chromedp.Evaluate(js, &result)) - if err != nil { + var captchaType string + if err := chromedp.Run(ctx, chromedp.Evaluate(detectCaptchaJS, &captchaType)); err != nil { if isContextErr(err) { return CaptchaInfo{}, err } - return CaptchaInfo{}, nil + return CaptchaInfo{}, nil // page script failed: treat as no CAPTCHA } - - found, _ := result["found"].(bool) - if !found { - return CaptchaInfo{}, nil - } - - return CaptchaInfo{ - Found: true, - Type: CaptchaTypeCloudflare, - Confidence: 0.90, - Description: "Cloudflare challenge page detected - wait or solve challenge", - }, nil + return CaptchaInfo{Found: captchaType != "", Type: captchaType}, nil } -// detectGenericCaptcha checks for generic image/text CAPTCHAs -func detectGenericCaptcha(ctx context.Context) (CaptchaInfo, error) { - js := `(function() { - var body = document.body.innerHTML.toLowerCase(); - var text = document.body.innerText.toLowerCase(); - - // Check for CAPTCHA-related keywords - var keywords = ['captcha', 'verification code', 'security code', 'enter the code', - 'type the characters', 'verify you are human', 'prove you are human', - 'i am not a robot', 'human verification']; - - for (var i = 0; i < keywords.length; i++) { - if (text.includes(keywords[i])) { - // Look for associated input and image - var img = document.querySelector('img[src*="captcha"], img[alt*="captcha"], .captcha-image'); - var input = document.querySelector('input[name*="captcha"], input[id*="captcha"], input[placeholder*="captcha" i]'); - - if (img || input) { - return {found: true, keyword: keywords[i], hasImage: !!img, hasInput: !!input}; - } - } - } - - // Check for CAPTCHA images even without keywords - var captchaImg = document.querySelector('img[src*="captcha"], img[alt*="captcha"]'); - if (captchaImg) { - return {found: true, type: 'image', src: captchaImg.src}; - } - - return {found: false}; - })()` - - var result map[string]interface{} - err := chromedp.Run(ctx, chromedp.Evaluate(js, &result)) - if err != nil { - if isContextErr(err) { - return CaptchaInfo{}, err - } - return CaptchaInfo{}, nil - } - - found, _ := result["found"].(bool) - if !found { - return CaptchaInfo{}, nil - } - - captchaType := CaptchaTypeUnknown - if imgType, ok := result["type"].(string); ok && imgType == "image" { - captchaType = CaptchaTypeImageCaptcha - } else if hasInput, ok := result["hasInput"].(bool); ok && hasInput { - captchaType = CaptchaTypeTextCaptcha - } - - description := "Generic CAPTCHA detected" - if keyword, ok := result["keyword"].(string); ok { - description = "CAPTCHA detected: " + keyword - } - - return CaptchaInfo{ - Found: true, - Type: captchaType, - Confidence: 0.75, - Description: description, - }, nil -} - -// IsCaptchaBlocking returns true if the CAPTCHA requires human intervention +// IsCaptchaBlocking returns true if the CAPTCHA requires human intervention. +// reCAPTCHA v3 is invisible and usually doesn't. func (c CaptchaInfo) IsCaptchaBlocking() bool { - if !c.Found { - return false - } - - // reCAPTCHA v3 is invisible and may not block - if c.Type == CaptchaTypeRecaptchaV3 { - return false - } + return c.Found && c.Type != CaptchaTypeRecaptchaV3 +} - return true +var captchaDescriptions = map[string]string{ + CaptchaTypeRecaptchaV2: "Google reCAPTCHA v2 - Click the checkbox and/or solve image puzzles", + CaptchaTypeRecaptchaV3: "Google reCAPTCHA v3 - Usually invisible, may auto-pass", + CaptchaTypeHCaptcha: "hCaptcha - Select images matching the description", + CaptchaTypeTurnstile: "Cloudflare Turnstile - Usually auto-passes after brief check", + CaptchaTypeFunCaptcha: "FunCaptcha - Complete interactive puzzles", + CaptchaTypeImageCaptcha: "Image CAPTCHA - Type the characters shown in the image", + CaptchaTypeTextCaptcha: "Text CAPTCHA - Enter the verification code", + CaptchaTypeCloudflare: "Cloudflare Challenge - Wait or complete verification", + CaptchaTypeUnknown: "Unknown CAPTCHA type - Manual inspection required", } // GetCaptchaDescription returns a human-readable description @@ -443,22 +91,8 @@ func (c CaptchaInfo) GetCaptchaDescription() string { if !c.Found { return "No CAPTCHA detected" } - - descriptions := map[string]string{ - CaptchaTypeRecaptchaV2: "Google reCAPTCHA v2 - Click the checkbox and/or solve image puzzles", - CaptchaTypeRecaptchaV3: "Google reCAPTCHA v3 - Usually invisible, may auto-pass", - CaptchaTypeHCaptcha: "hCaptcha - Select images matching the description", - CaptchaTypeTurnstile: "Cloudflare Turnstile - Usually auto-passes after brief check", - CaptchaTypeFunCaptcha: "FunCaptcha - Complete interactive puzzles", - CaptchaTypeImageCaptcha: "Image CAPTCHA - Type the characters shown in the image", - CaptchaTypeTextCaptcha: "Text CAPTCHA - Enter the verification code", - CaptchaTypeCloudflare: "Cloudflare Challenge - Wait or complete verification", - CaptchaTypeUnknown: "Unknown CAPTCHA type - Manual inspection required", - } - - if desc, ok := descriptions[c.Type]; ok { + if desc, ok := captchaDescriptions[c.Type]; ok { return desc } - - return c.Description + return captchaDescriptions[CaptchaTypeUnknown] } diff --git a/internal/browser/captcha_test.go b/internal/browser/captcha_test.go index 477a862..0a45027 100644 --- a/internal/browser/captcha_test.go +++ b/internal/browser/captcha_test.go @@ -3,7 +3,7 @@ package browser import "testing" // IsCaptchaBlocking and GetCaptchaDescription are pure methods on an -// already-populated CaptchaInfo - unlike the detectXxx family (which drive a +// already-populated CaptchaInfo - unlike detectCaptcha (which drives a // live browser via chromedp and need a real Chrome instance, see // browser_chrome_test.go), these are cheap to check directly. @@ -45,11 +45,7 @@ func TestCaptchaInfoGetCaptchaDescription(t *testing.T) { {"not found", CaptchaInfo{Found: false}, "No CAPTCHA detected"}, {"recaptcha v2 has a known description", CaptchaInfo{Found: true, Type: CaptchaTypeRecaptchaV2}, "Google reCAPTCHA v2 - Click the checkbox and/or solve image puzzles"}, {"turnstile has a known description", CaptchaInfo{Found: true, Type: CaptchaTypeTurnstile}, "Cloudflare Turnstile - Usually auto-passes after brief check"}, - { - "unrecognized type falls back to the detector's own Description", - CaptchaInfo{Found: true, Type: "some_future_captcha_type", Description: "custom detector description"}, - "custom detector description", - }, + {"unrecognized type falls back to unknown", CaptchaInfo{Found: true, Type: "some_future_captcha_type"}, "Unknown CAPTCHA type - Manual inspection required"}, } for _, tt := range tests { From 4478c1f41f04ef67e64925db45bb9af09554447c Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sun, 27 Sep 2026 17:51:10 +0300 Subject: [PATCH 07/11] refactor(web): drop restart-resume of paused send jobs A job paused by the daily cap used to be saved to pending_job*.json and resumed on the next 'serve' start. Its remaining list is exactly what 'Send all' (eligible) or an automated cycle picks up next anyway, and it needed a history re-check to avoid double-sending. Removes JobPersistence, PersistentJobState, checkPendingJob, resumePendingJob and saveJobProgress. Leftover pending_job*.json files are ignored. --- docs/multi-profile.md | 1 - internal/web/handlers_jobs.go | 173 +--------------------------------- internal/web/job.go | 82 ---------------- internal/web/job_test.go | 56 ----------- internal/web/server.go | 57 +++++------ 5 files changed, 29 insertions(+), 340 deletions(-) diff --git a/docs/multi-profile.md b/docs/multi-profile.md index 3b717ef..8e44a51 100644 --- a/docs/multi-profile.md +++ b/docs/multi-profile.md @@ -94,7 +94,6 @@ just `inbox`. - `renderWithCSRF` injects `Profiles`/`ActiveProfile`/`CurrentPath` into every page's template data, so `layout.html`'s nav can render the switcher unconditionally without every handler wiring it manually - The switcher itself is a `