Files
go-onlyoffice/catalog/thunderbird.go
T
eSlider 256864f613
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Failing after 2s
Tests / Test (Go 1.25) (push) Successful in 1m22s
Tests / Test (Go stable) (push) Successful in 1m23s
feat(catalog): port oo catalog (scan/merge/match/apply) from legacy branch
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).
2026-08-18 12:41:58 +01:00

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
}