chore: keep host/client specifics out of the tree (env & config)
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
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
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).
This commit is contained in:
+17
-5
@@ -100,26 +100,38 @@ END:VCARD
|
||||
|
||||
func TestScanProjectsRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repo := filepath.Join(root, "produktor-demo")
|
||||
repo := filepath.Join(root, "acme-demo")
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := ScanProjectsRoot(root, 3)
|
||||
cl := &Classifier{WorkNames: []string{"acme"}}
|
||||
doc, err := ScanProjectsRootOpts(root, 3, ScanOptions{Classifier: cl})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range doc.Entries {
|
||||
if e.Kind == "company" && e.Name == "produktor-demo" {
|
||||
if e.Kind == "company" && e.Name == "acme-demo" {
|
||||
found = true
|
||||
if e.Role != "work" {
|
||||
t.Fatalf("role=%q", e.Role)
|
||||
if e.Role != "work" || e.Zone != "warm" {
|
||||
t.Fatalf("role=%q zone=%q", e.Role, e.Zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing company: %+v", doc.Entries)
|
||||
}
|
||||
|
||||
// Neutral default leaves it unclassified.
|
||||
doc, err = ScanProjectsRoot(root, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range doc.Entries {
|
||||
if e.Kind == "company" && e.Name == "acme-demo" && e.Role != "unknown" {
|
||||
t.Fatalf("neutral role=%q", e.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryID(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Deployment classification rules for `oo catalog scan-projects` /
|
||||
# `oo catalog scan-thunderbird`. Point OO_CATALOG_CONFIG (or --config) at a copy
|
||||
# of this file. Keep your real rules out of the repository — they name your
|
||||
# clients and hosts. The library defaults to no rules (nothing is "work").
|
||||
#
|
||||
# work_remotes: a git remote containing any of these substrings → work/hot.
|
||||
work_remotes:
|
||||
- git.internal.example
|
||||
- github.com/acme
|
||||
#
|
||||
# work_names: a project directory name containing any of these substrings → work/warm.
|
||||
work_names:
|
||||
- acme
|
||||
#
|
||||
# mail_orgs: ordered rules for Thunderbird/mbox identities; the first match wins.
|
||||
# Match by exact `domain`, `suffix` (e.g. ".example.com") and/or display `name`.
|
||||
# `zone` defaults to hot, `role` to work.
|
||||
mail_orgs:
|
||||
- domain: acme.example
|
||||
org: Acme GmbH
|
||||
zone: hot
|
||||
role: work
|
||||
- suffix: .gov.example
|
||||
org: Public Sector
|
||||
zone: warm
|
||||
role: work
|
||||
@@ -0,0 +1,127 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Classifier maps project trees and mail identities to catalog org/zone/role.
|
||||
//
|
||||
// The library ships with neutral defaults: nothing is classified as work unless
|
||||
// the deployment supplies rules. Those rules are deployment-specific, so they
|
||||
// live in a YAML config file (path from OO_CATALOG_CONFIG or the --config flag),
|
||||
// not in the code. See catalog/classify.example.yaml.
|
||||
type Classifier struct {
|
||||
// WorkRemotes: a git remote containing any of these substrings → work/hot.
|
||||
WorkRemotes []string `yaml:"work_remotes,omitempty"`
|
||||
// WorkNames: a project name containing any of these substrings → work/warm.
|
||||
WorkNames []string `yaml:"work_names,omitempty"`
|
||||
// MailOrgs: ordered mail-identity rules; the first match wins.
|
||||
MailOrgs []MailRule `yaml:"mail_orgs,omitempty"`
|
||||
}
|
||||
|
||||
// MailRule maps an email domain and/or a display-name substring to an org with
|
||||
// a zone/role. At least one of Domain, Suffix or Name must be set.
|
||||
type MailRule struct {
|
||||
Domain string `yaml:"domain,omitempty"` // exact domain, case-insensitive
|
||||
Suffix string `yaml:"suffix,omitempty"` // domain suffix, e.g. ".example.com"
|
||||
Name string `yaml:"name,omitempty"` // substring of the display name
|
||||
Org string `yaml:"org"`
|
||||
Zone string `yaml:"zone,omitempty"` // default "hot"
|
||||
Role string `yaml:"role,omitempty"` // default "work"
|
||||
}
|
||||
|
||||
// DefaultClassifier returns the neutral classifier (no deployment rules).
|
||||
func DefaultClassifier() *Classifier { return &Classifier{} }
|
||||
|
||||
// LoadClassifier reads a classifier config from a YAML file.
|
||||
func LoadClassifier(path string) (*Classifier, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Classifier
|
||||
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse classifier config %s: %w", path, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// LoadClassifierFromEnv loads the classifier named by OO_CATALOG_CONFIG. An
|
||||
// empty variable yields the neutral classifier.
|
||||
func LoadClassifierFromEnv() (*Classifier, error) {
|
||||
path := strings.TrimSpace(os.Getenv("OO_CATALOG_CONFIG"))
|
||||
if path == "" {
|
||||
return DefaultClassifier(), nil
|
||||
}
|
||||
return LoadClassifier(path)
|
||||
}
|
||||
|
||||
// ClassifyProject returns (role, zone) for a project name and git remote.
|
||||
// Generic name heuristics come first; deployment rules supply the work cases.
|
||||
func (c *Classifier) ClassifyProject(name, remote string) (role, zone string) {
|
||||
lower := strings.ToLower(name)
|
||||
remoteL := strings.ToLower(remote)
|
||||
switch {
|
||||
case strings.Contains(lower, "experiment") || strings.HasPrefix(lower, "test"):
|
||||
return "experiment", "cold"
|
||||
case lower == "mama" || lower == "personal" || strings.Contains(lower, "private"):
|
||||
return "personal", "private"
|
||||
}
|
||||
if c != nil {
|
||||
for _, r := range c.WorkRemotes {
|
||||
if r != "" && strings.Contains(remoteL, strings.ToLower(r)) {
|
||||
return "work", "hot"
|
||||
}
|
||||
}
|
||||
for _, n := range c.WorkNames {
|
||||
if n != "" && strings.Contains(lower, strings.ToLower(n)) {
|
||||
return "work", "warm"
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown", "warm"
|
||||
}
|
||||
|
||||
// ClassifyMail returns (org, zone, role) for a mail identity. name is the
|
||||
// display name (may be empty); email is the address.
|
||||
func (c *Classifier) ClassifyMail(name, email string) (org, zone, role string) {
|
||||
em := NormalizeEmail(email)
|
||||
_, domain, _ := strings.Cut(em, "@")
|
||||
nameL := strings.ToLower(strings.TrimSpace(name))
|
||||
if c != nil {
|
||||
for _, r := range c.MailOrgs {
|
||||
if !mailRuleMatches(r, domain, nameL) {
|
||||
continue
|
||||
}
|
||||
z, ro := r.Zone, r.Role
|
||||
if z == "" {
|
||||
z = "hot"
|
||||
}
|
||||
if ro == "" {
|
||||
ro = "work"
|
||||
}
|
||||
return r.Org, z, ro
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(domain, ".de") && looksPublicSector(domain) {
|
||||
return domain, "warm", "work"
|
||||
}
|
||||
return "", "private", "unknown"
|
||||
}
|
||||
|
||||
func mailRuleMatches(r MailRule, domain, nameL string) bool {
|
||||
if r.Domain != "" && domain == strings.ToLower(strings.TrimSpace(r.Domain)) {
|
||||
return true
|
||||
}
|
||||
if r.Suffix != "" && strings.HasSuffix(domain, strings.ToLower(strings.TrimSpace(r.Suffix))) {
|
||||
return true
|
||||
}
|
||||
if r.Name != "" && nameL != "" && strings.Contains(nameL, strings.ToLower(strings.TrimSpace(r.Name))) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifierRules(t *testing.T) {
|
||||
cl := &Classifier{
|
||||
WorkRemotes: []string{"git.internal.example"},
|
||||
WorkNames: []string{"acme"},
|
||||
MailOrgs: []MailRule{
|
||||
{Domain: "acme.example", Org: "Acme", Zone: "hot", Role: "work"},
|
||||
{Suffix: ".gov.example", Org: "Public", Zone: "warm", Role: "work"},
|
||||
},
|
||||
}
|
||||
if role, zone := cl.ClassifyProject("acme-app", "git@git.internal.example:team/acme-app.git"); role != "work" || zone != "hot" {
|
||||
t.Fatalf("remote: %s/%s", role, zone)
|
||||
}
|
||||
if role, zone := cl.ClassifyProject("acme-demo", ""); role != "work" || zone != "warm" {
|
||||
t.Fatalf("name: %s/%s", role, zone)
|
||||
}
|
||||
if role, zone := cl.ClassifyProject("experiment-x", ""); role != "experiment" || zone != "cold" {
|
||||
t.Fatalf("experiment: %s/%s", role, zone)
|
||||
}
|
||||
if org, zone, role := cl.ClassifyMail("", "bob@acme.example"); org != "Acme" || zone != "hot" || role != "work" {
|
||||
t.Fatalf("mail: %s/%s/%s", org, zone, role)
|
||||
}
|
||||
if org, _, _ := cl.ClassifyMail("X", "x@team.gov.example"); org != "Public" {
|
||||
t.Fatalf("suffix: %s", org)
|
||||
}
|
||||
if org, zone, role := cl.ClassifyMail("", "someone@unknown.example"); org != "" || zone != "private" || role != "unknown" {
|
||||
t.Fatalf("neutral: %s/%s/%s", org, zone, role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClassifier(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "classify.yaml")
|
||||
if err := os.WriteFile(path, []byte("work_names:\n - acme\nmail_orgs:\n - domain: acme.example\n org: Acme\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl, err := LoadClassifier(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role, _ := cl.ClassifyProject("acme-app", ""); role != "work" {
|
||||
t.Fatalf("role=%s", role)
|
||||
}
|
||||
if org, zone, _ := cl.ClassifyMail("", "a@acme.example"); org != "Acme" || zone != "hot" {
|
||||
t.Fatalf("org=%s zone=%s", org, zone)
|
||||
}
|
||||
|
||||
t.Setenv("OO_CATALOG_CONFIG", path)
|
||||
if envCl, err := LoadClassifierFromEnv(); err != nil || envCl == nil || len(envCl.WorkNames) == 0 {
|
||||
t.Fatalf("env: %+v %v", envCl, err)
|
||||
}
|
||||
t.Setenv("OO_CATALOG_CONFIG", "")
|
||||
neutral, err := LoadClassifierFromEnv()
|
||||
if err != nil || len(neutral.WorkNames) != 0 || len(neutral.MailOrgs) != 0 {
|
||||
t.Fatalf("neutral: %+v %v", neutral, err)
|
||||
}
|
||||
}
|
||||
+11
-5
@@ -16,6 +16,8 @@ type ScanOptions struct {
|
||||
MboxHeaders bool
|
||||
// MboxMaxBytes skips individual mbox files larger than this (0 = 256 MiB default).
|
||||
MboxMaxBytes int64
|
||||
// Classifier supplies deployment classification rules; nil → neutral default.
|
||||
Classifier *Classifier
|
||||
}
|
||||
|
||||
// ScanThunderbirdRoot finds Thunderbird profiles under root and emits person rows
|
||||
@@ -37,6 +39,10 @@ func ScanThunderbirdRootOpts(root string, opts ScanOptions) (*Document, error) {
|
||||
if opts.MboxMaxBytes <= 0 {
|
||||
opts.MboxMaxBytes = 256 << 20
|
||||
}
|
||||
cl := opts.Classifier
|
||||
if cl == nil {
|
||||
cl = DefaultClassifier()
|
||||
}
|
||||
|
||||
var entries []Entry
|
||||
seenDB := map[string]struct{}{}
|
||||
@@ -64,7 +70,7 @@ func ScanThunderbirdRootOpts(root string, opts ScanOptions) (*Document, error) {
|
||||
return nil
|
||||
}
|
||||
seenDB[path] = struct{}{}
|
||||
parsed, perr := parseGlodaContacts(path)
|
||||
parsed, perr := parseGlodaContacts(path, cl)
|
||||
if perr != nil {
|
||||
entries = append(entries, Entry{
|
||||
ID: EntryID("person", "", filepath.Base(path)),
|
||||
@@ -84,7 +90,7 @@ func ScanThunderbirdRootOpts(root string, opts ScanOptions) (*Document, error) {
|
||||
return nil
|
||||
}
|
||||
seenMAB[path] = struct{}{}
|
||||
parsed, perr := parseMABEmails(path)
|
||||
parsed, perr := parseMABEmails(path, cl)
|
||||
if perr != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -101,7 +107,7 @@ func ScanThunderbirdRootOpts(root string, opts ScanOptions) (*Document, error) {
|
||||
if info.Size() > opts.MboxMaxBytes {
|
||||
return nil
|
||||
}
|
||||
parsed, perr := parseMboxHeaderEmails(path)
|
||||
parsed, perr := parseMboxHeaderEmails(path, cl)
|
||||
if perr != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -150,7 +156,7 @@ func isLikelyMboxFile(name, path string) bool {
|
||||
}
|
||||
|
||||
// parseMboxHeaderEmails extracts addresses from From/To/Cc/Reply-To headers only.
|
||||
func parseMboxHeaderEmails(path string) ([]Entry, error) {
|
||||
func parseMboxHeaderEmails(path string, cl *Classifier) ([]Entry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -220,7 +226,7 @@ func parseMboxHeaderEmails(path string) ([]Entry, error) {
|
||||
|
||||
var out []Entry
|
||||
for em := range emails {
|
||||
org, zone, role := classifyMailIdentity("", em)
|
||||
org, zone, role := cl.ClassifyMail("", em)
|
||||
out = append(out, Entry{
|
||||
ID: EntryID("person", em, ""),
|
||||
Kind: "person",
|
||||
|
||||
@@ -10,22 +10,22 @@ func TestParseMboxHeaderEmails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "INBOX")
|
||||
body := `From - Mon Jul 1 00:00:00 2016
|
||||
From: Axel Schaefer <axel.schaefer@wheregroup.com>
|
||||
To: Andriy Oblivantsev <andriy.oblivantsev@wheregroup.com>
|
||||
From: Alice Smith <alice.smith@acme.example>
|
||||
To: Bob Jones <bob.jones@acme.example>
|
||||
Cc: noreply@example.com, client@stadt-example.de
|
||||
Subject: test
|
||||
|
||||
Body line ignored
|
||||
From - Mon Jul 2 00:00:00 2016
|
||||
From: Someone <paul.schmidt@wheregroup.com>
|
||||
To: list@wheregroup.com
|
||||
From: Someone <paul.schmidt@acme.example>
|
||||
To: list@acme.example
|
||||
|
||||
more body
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, err := parseMboxHeaderEmails(path)
|
||||
ents, err := parseMboxHeaderEmails(path, DefaultClassifier())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ more body
|
||||
got[e.Emails[0]] = true
|
||||
}
|
||||
}
|
||||
if !got["axel.schaefer@wheregroup.com"] || !got["andriy.oblivantsev@wheregroup.com"] {
|
||||
if !got["alice.smith@acme.example"] || !got["bob.jones@acme.example"] {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
if got["noreply@example.com"] {
|
||||
@@ -53,7 +53,7 @@ func TestScanThunderbirdRootOptsMbox(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(imap, "INBOX"), []byte(
|
||||
"From - x\nFrom: a@wheregroup.com\nTo: b@wheregroup.com\n\nbody\n",
|
||||
"From - x\nFrom: a@acme.example\nTo: b@acme.example\n\nbody\n",
|
||||
), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+14
-24
@@ -10,10 +10,19 @@ import (
|
||||
|
||||
// ScanProjectsRoot finds git roots under root (max depth) and emits company rows.
|
||||
func ScanProjectsRoot(root string, maxDepth int) (*Document, error) {
|
||||
return ScanProjectsRootOpts(root, maxDepth, ScanOptions{})
|
||||
}
|
||||
|
||||
// ScanProjectsRootOpts is ScanProjectsRoot with deployment classification rules.
|
||||
func ScanProjectsRootOpts(root string, maxDepth int, opts ScanOptions) (*Document, error) {
|
||||
root = filepath.Clean(root)
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = 4
|
||||
}
|
||||
cl := opts.Classifier
|
||||
if cl == nil {
|
||||
cl = DefaultClassifier()
|
||||
}
|
||||
st, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -23,7 +32,7 @@ func ScanProjectsRoot(root string, maxDepth int) (*Document, error) {
|
||||
}
|
||||
|
||||
var entries []Entry
|
||||
err = walkGitRoots(root, root, 0, maxDepth, &entries)
|
||||
err = walkGitRoots(root, root, 0, maxDepth, cl, &entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -43,7 +52,7 @@ func ScanProjectsRoot(root string, maxDepth int) (*Document, error) {
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
id := EntryID("company", "", name)
|
||||
role, zone := classifyProjectName(name, "")
|
||||
role, zone := cl.ClassifyProject(name, "")
|
||||
entries = append(entries, Entry{
|
||||
ID: id,
|
||||
Kind: "company",
|
||||
@@ -59,7 +68,7 @@ func ScanProjectsRoot(root string, maxDepth int) (*Document, error) {
|
||||
return MergeDocs(&Document{Entries: entries}), nil
|
||||
}
|
||||
|
||||
func walkGitRoots(root, dir string, depth, maxDepth int, out *[]Entry) error {
|
||||
func walkGitRoots(root, dir string, depth, maxDepth int, cl *Classifier, out *[]Entry) error {
|
||||
if depth > maxDepth {
|
||||
return nil
|
||||
}
|
||||
@@ -67,7 +76,7 @@ func walkGitRoots(root, dir string, depth, maxDepth int, out *[]Entry) error {
|
||||
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && (st.IsDir() || st.Mode().IsRegular()) {
|
||||
name := filepath.Base(dir)
|
||||
remote := gitRemoteOrigin(dir)
|
||||
role, zone := classifyProjectName(name, remote)
|
||||
role, zone := cl.ClassifyProject(name, remote)
|
||||
*out = append(*out, Entry{
|
||||
ID: EntryID("company", "", name),
|
||||
Kind: "company",
|
||||
@@ -93,7 +102,7 @@ func walkGitRoots(root, dir string, depth, maxDepth int, out *[]Entry) error {
|
||||
if name == ".git" || name == "node_modules" || name == "vendor" || name == ".venv" || name == "dist" {
|
||||
continue
|
||||
}
|
||||
_ = walkGitRoots(root, filepath.Join(dir, name), depth+1, maxDepth, out)
|
||||
_ = walkGitRoots(root, filepath.Join(dir, name), depth+1, maxDepth, cl, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -106,22 +115,3 @@ func gitRemoteOrigin(dir string) string {
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func classifyProjectName(name, remote string) (role, zone string) {
|
||||
lower := strings.ToLower(name)
|
||||
remoteL := strings.ToLower(remote)
|
||||
switch {
|
||||
case strings.Contains(lower, "experiment") || strings.HasPrefix(lower, "test"):
|
||||
return "experiment", "cold"
|
||||
case lower == "mama" || lower == "personal" || strings.Contains(lower, "private"):
|
||||
return "personal", "private"
|
||||
case strings.Contains(remoteL, "git.produktor.io") || strings.Contains(remoteL, "github.com/eslider"):
|
||||
return "work", "hot"
|
||||
case strings.Contains(lower, "produktor") || strings.Contains(lower, "eslider") ||
|
||||
strings.Contains(lower, "asesoria") || strings.Contains(lower, "dyvenia") ||
|
||||
strings.Contains(lower, "onlyoffice"):
|
||||
return "work", "warm"
|
||||
default:
|
||||
return "unknown", "warm"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-23
@@ -64,7 +64,7 @@ func noisyEmail(email string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func parseMABEmails(path string) ([]Entry, error) {
|
||||
func parseMABEmails(path string, cl *Classifier) ([]Entry, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -76,7 +76,7 @@ func parseMABEmails(path string) ([]Entry, error) {
|
||||
if noisyEmail(em) {
|
||||
continue
|
||||
}
|
||||
org, zone, role := classifyMailIdentity("", em)
|
||||
org, zone, role := cl.ClassifyMail("", em)
|
||||
out = append(out, Entry{
|
||||
ID: EntryID("person", em, ""),
|
||||
Kind: "person",
|
||||
@@ -93,7 +93,7 @@ func parseMABEmails(path string) ([]Entry, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseGlodaContacts(dbPath string) ([]Entry, error) {
|
||||
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)
|
||||
@@ -137,7 +137,7 @@ func parseGlodaContacts(dbPath string) ([]Entry, error) {
|
||||
if display != "" {
|
||||
first, last = SplitDisplayName(display)
|
||||
}
|
||||
org, zone, role := classifyMailIdentity(display, em)
|
||||
org, zone, role := cl.ClassifyMail(display, em)
|
||||
out = append(out, Entry{
|
||||
ID: EntryID("person", em, display),
|
||||
Kind: "person",
|
||||
@@ -157,25 +157,6 @@ func parseGlodaContacts(dbPath string) ([]Entry, error) {
|
||||
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{
|
||||
|
||||
+23
-11
@@ -16,8 +16,8 @@ func TestNoisyEmail(t *testing.T) {
|
||||
if !noisyEmail("x@marketplace.amazon.de") {
|
||||
t.Fatal("amazon marketplace")
|
||||
}
|
||||
if noisyEmail("andriy.oblivantsev@wheregroup.com") {
|
||||
t.Fatal("should keep wheregroup")
|
||||
if noisyEmail("alice.smith@acme.example") {
|
||||
t.Fatal("should keep a human work address")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,15 @@ func TestParseMABEmails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "abook.mab")
|
||||
body := `// mork junk
|
||||
PrimaryEmail=andriy.oblivantsev@wheregroup.com
|
||||
PrimaryEmail=alice.smith@acme.example
|
||||
noreply@github.com
|
||||
axel.schaefer@wheregroup.com
|
||||
bob.jones@acme.example
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, err := parseMABEmails(path)
|
||||
// Neutral default: no deployment rules → unclassified.
|
||||
ents, err := parseMABEmails(path, DefaultClassifier())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -40,7 +41,18 @@ func TestParseMABEmails(t *testing.T) {
|
||||
t.Fatalf("got %d: %+v", len(ents), ents)
|
||||
}
|
||||
for _, e := range ents {
|
||||
if e.Org != "WhereGroup" || e.Role != "work" {
|
||||
if e.Org != "" || e.Role != "unknown" {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
}
|
||||
// A deployment rule classifies the domain as work.
|
||||
cl := &Classifier{MailOrgs: []MailRule{{Domain: "acme.example", Org: "Acme", Zone: "warm", Role: "work"}}}
|
||||
ents, err = parseMABEmails(path, cl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range ents {
|
||||
if e.Org != "Acme" || e.Role != "work" || e.Zone != "warm" {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
}
|
||||
@@ -56,8 +68,8 @@ func TestParseGlodaContacts(t *testing.T) {
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE contacts (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE identities (id INTEGER PRIMARY KEY, contactID INTEGER, kind TEXT, value TEXT);
|
||||
INSERT INTO contacts VALUES (1, 'Axel Schaefer');
|
||||
INSERT INTO identities VALUES (1, 1, 'email', 'axel.schaefer@wheregroup.com');
|
||||
INSERT INTO contacts VALUES (1, 'Alice Smith');
|
||||
INSERT INTO identities VALUES (1, 1, 'email', 'alice.smith@acme.example');
|
||||
INSERT INTO contacts VALUES (2, 'Noise Bot');
|
||||
INSERT INTO identities VALUES (2, 2, 'email', 'noreply@example.com');
|
||||
`)
|
||||
@@ -66,14 +78,14 @@ func TestParseGlodaContacts(t *testing.T) {
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
ents, err := parseGlodaContacts(dbPath)
|
||||
ents, err := parseGlodaContacts(dbPath, DefaultClassifier())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ents) != 1 {
|
||||
t.Fatalf("got %d %+v", len(ents), ents)
|
||||
}
|
||||
if ents[0].First != "Axel" || ents[0].Emails[0] != "axel.schaefer@wheregroup.com" {
|
||||
if ents[0].First != "Alice" || ents[0].Emails[0] != "alice.smith@acme.example" {
|
||||
t.Fatalf("%+v", ents[0])
|
||||
}
|
||||
}
|
||||
@@ -84,7 +96,7 @@ func TestScanThunderbirdRoot(t *testing.T) {
|
||||
if err := os.MkdirAll(prof, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(prof, "abook.mab"), []byte("mail=paul.schmidt@wheregroup.com\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(prof, "abook.mab"), []byte("mail=paul.schmidt@acme.example\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := ScanThunderbirdRoot(root)
|
||||
|
||||
Reference in New Issue
Block a user