Files
eSlider afb93feae5
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 59s
Tests / Test (Go stable) (pull_request) Successful in 1m7s
chore: keep host/client specifics out of the tree (env & config)
Showroom-safe: the tree no longer carries internal hosts, IPs, ports,
personal names, client domains or client file names. Behaviour is
unchanged and now supplied per deployment.

- catalog: hardcoded mail-org / project classifiers become a YAML-driven
  Classifier (OO_CATALOG_CONFIG or --config); neutral default classifies
  nothing as work. New catalog/classify.go + example + tests.
- storage_fallback: drop the baked-in MinIO endpoint IP; require
  MINIO_ENDPOINT (+ keys) from the env.
- kontolink: build DocEditor links from $ONLYOFFICE_URL instead of a
  hardcoded portal host; kontoblatt: no client file id in the output name.
- oo: load .env CLI-wide (bootstrap.LoadEnv in execute) so
  non-authenticating commands (catalog scans) also see config.
- genericize comments/docs/fixtures (AGENTS, README, .env.example,
  crm-associations, catalog tests, ES/pdfattach tests, mails).
2026-09-22 22:03:36 +01:00

173 lines
4.3 KiB
Go

package catalog
import (
"database/sql"
"os"
"regexp"
"strings"
_ "modernc.org/sqlite"
)
var emailRE = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
// noisyEmail reports addresses that should not become CRM catalog persons.
func noisyEmail(email string) bool {
e := NormalizeEmail(email)
if e == "" || !strings.Contains(e, "@") {
return true
}
local, domain, ok := strings.Cut(e, "@")
if !ok {
return true
}
noiseLocal := []string{
"noreply", "no-reply", "donotreply", "mailer-daemon", "postmaster",
"bounce", "notifications", "newsletter", "unsubscribe",
"root", "admin", "abuse", "webmaster", "hostmaster",
}
for _, p := range noiseLocal {
if local == p || strings.Contains(local, p) {
return true
}
}
// role / shared mailboxes (keep human dotted names)
roleExact := map[string]struct{}{
"info": {}, "alle": {}, "all": {}, "office": {}, "office@": {},
"wartung": {}, "starface": {}, "gitlab": {}, "pl": {}, "gf": {},
"umsetzung": {}, "support": {}, "sales": {}, "billing": {},
}
if _, ok := roleExact[local]; ok {
return true
}
if strings.HasSuffix(local, "-request") || strings.HasSuffix(local, "-owner") {
return true
}
noiseDomain := []string{
"marketplace.amazon.", "reply.github.com", "users.noreply.github.com",
"groups.facebook.com", "googlegroups.com", "yahoogroups.",
"email.apple.com", "amazonses.com", "mailchimp.com",
"sendgrid.net", "mailgun.org", "postmarkapp.com",
"property.booking.com", "transvascular.com",
}
for _, p := range noiseDomain {
if strings.Contains(domain, p) {
return true
}
}
// random marketplace / tracking locals
if len(local) >= 16 && !strings.Contains(local, ".") && !strings.Contains(local, "-") {
if strings.Contains(domain, "amazon.") {
return true
}
}
return false
}
func parseMABEmails(path string, cl *Classifier) ([]Entry, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
found := emailRE.FindAllString(string(b), -1)
var out []Entry
for _, raw := range found {
em := NormalizeEmail(raw)
if noisyEmail(em) {
continue
}
org, zone, role := cl.ClassifyMail("", em)
out = append(out, Entry{
ID: EntryID("person", em, ""),
Kind: "person",
Emails: []string{em},
Org: org,
Sources: []string{path},
Zone: zone,
Role: role,
Approve: false,
Status: "new",
Notes: "thunderbird_mab",
})
}
return out, nil
}
func parseGlodaContacts(dbPath string, cl *Classifier) ([]Entry, error) {
// read-only URI; immutable=1 helps when WAL/shm are missing
dsn := "file:" + dbPath + "?mode=ro&_pragma=query_only(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
defer db.Close()
rows, err := db.Query(`
SELECT COALESCE(c.name, ''), i.value
FROM identities i
LEFT JOIN contacts c ON c.id = i.contactID
WHERE lower(i.kind) = 'email' AND i.value IS NOT NULL AND i.value != ''
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entry
for rows.Next() {
var name, value string
if err := rows.Scan(&name, &value); err != nil {
return out, err
}
em := NormalizeEmail(value)
// Gloda sometimes stores "Name <email>" in value
if m := emailRE.FindString(em); m != "" {
em = NormalizeEmail(m)
}
if noisyEmail(em) {
continue
}
display := strings.TrimSpace(name)
// strip wrapping quotes / email leftovers
display = strings.Trim(display, `"'`)
if idx := strings.Index(display, "<"); idx > 0 {
display = strings.TrimSpace(display[:idx])
}
first, last := "", ""
if display != "" {
first, last = SplitDisplayName(display)
}
org, zone, role := cl.ClassifyMail(display, em)
out = append(out, Entry{
ID: EntryID("person", em, display),
Kind: "person",
Name: display,
First: first,
Last: last,
Emails: []string{em},
Org: org,
Sources: []string{dbPath},
Zone: zone,
Role: role,
Approve: false,
Status: "new",
Notes: "thunderbird_gloda",
})
}
return out, rows.Err()
}
func looksPublicSector(domain string) bool {
d := strings.ToLower(domain)
hints := []string{
"stadt-", "stadt.", "kreis-", "gemeinde", "landkreis",
"bund.", "bundes", "lvermgeo", "rvr.", "eba.",
}
for _, h := range hints {
if strings.Contains(d, h) {
return true
}
}
return false
}