The catalog feature (approved ASR-0014) was lost during the GitHub history rewrite: the merge commit on main is empty and the code only survived on the old gitea-history branches (no merge base with main). Port catalog/* and the oo catalog CLI verbatim; drop the library-only applications package whose Sync() created the 'Write application'/'Apply application'/'Send CV' CRM task spam (23k+ tasks deleted 2026-08-18).
192 lines
5.0 KiB
Go
192 lines
5.0 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) ([]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 := classifyMailIdentity("", 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) ([]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 := classifyMailIdentity(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 classifyMailIdentity(name, email string) (org, zone, role string) {
|
|
em := NormalizeEmail(email)
|
|
_, domain, _ := strings.Cut(em, "@")
|
|
switch {
|
|
case domain == "wheregroup.com" || strings.Contains(strings.ToLower(name), "wheregroup"):
|
|
return "WhereGroup", "warm", "work"
|
|
case domain == "produktor.io" || domain == "eslider.de" || strings.HasSuffix(domain, ".produktor.io"):
|
|
return "produktor.io", "hot", "work"
|
|
case domain == "dyvenia.com":
|
|
return "Dyvenia", "warm", "work"
|
|
case domain == "immowelt.de" || domain == "immowelt.com":
|
|
return "Immowelt", "warm", "work"
|
|
case strings.HasSuffix(domain, ".de") && looksPublicSector(domain):
|
|
return domain, "warm", "work"
|
|
default:
|
|
return "", "private", "unknown"
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|