@agenticmail/enterprise 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/ARCHITECTURE.md +183 -0
  2. package/agenticmail-enterprise.db +0 -0
  3. package/dashboards/README.md +120 -0
  4. package/dashboards/dotnet/Program.cs +261 -0
  5. package/dashboards/express/app.js +146 -0
  6. package/dashboards/go/main.go +513 -0
  7. package/dashboards/html/index.html +535 -0
  8. package/dashboards/java/AgenticMailDashboard.java +376 -0
  9. package/dashboards/php/index.php +414 -0
  10. package/dashboards/python/app.py +273 -0
  11. package/dashboards/ruby/app.rb +195 -0
  12. package/dist/chunk-77IDQJL3.js +7 -0
  13. package/dist/chunk-7RGCCHIT.js +115 -0
  14. package/dist/chunk-DXNKR3TG.js +1355 -0
  15. package/dist/chunk-IQWA44WT.js +970 -0
  16. package/dist/chunk-LCUZGIDH.js +965 -0
  17. package/dist/chunk-N2JVTNNJ.js +2553 -0
  18. package/dist/chunk-O462UJBH.js +363 -0
  19. package/dist/chunk-PNKVD2UK.js +26 -0
  20. package/dist/cli.js +218 -0
  21. package/dist/dashboard/index.html +558 -0
  22. package/dist/db-adapter-DEWEFNIV.js +7 -0
  23. package/dist/dynamodb-CCGL2E77.js +426 -0
  24. package/dist/engine/index.js +1261 -0
  25. package/dist/index.js +522 -0
  26. package/dist/mongodb-ODTXIVPV.js +319 -0
  27. package/dist/mysql-RM3S2FV5.js +521 -0
  28. package/dist/postgres-LN7A6MGQ.js +518 -0
  29. package/dist/routes-2JEPIIKC.js +441 -0
  30. package/dist/routes-74ZLKJKP.js +399 -0
  31. package/dist/server.js +7 -0
  32. package/dist/sqlite-3K5YOZ4K.js +439 -0
  33. package/dist/turso-LDWODSDI.js +442 -0
  34. package/package.json +49 -0
  35. package/src/admin/routes.ts +331 -0
  36. package/src/auth/routes.ts +130 -0
  37. package/src/cli.ts +260 -0
  38. package/src/dashboard/index.html +558 -0
  39. package/src/db/adapter.ts +230 -0
  40. package/src/db/dynamodb.ts +456 -0
  41. package/src/db/factory.ts +51 -0
  42. package/src/db/mongodb.ts +360 -0
  43. package/src/db/mysql.ts +472 -0
  44. package/src/db/postgres.ts +479 -0
  45. package/src/db/sql-schema.ts +123 -0
  46. package/src/db/sqlite.ts +391 -0
  47. package/src/db/turso.ts +411 -0
  48. package/src/deploy/fly.ts +368 -0
  49. package/src/deploy/managed.ts +213 -0
  50. package/src/engine/activity.ts +474 -0
  51. package/src/engine/agent-config.ts +429 -0
  52. package/src/engine/agenticmail-bridge.ts +296 -0
  53. package/src/engine/approvals.ts +278 -0
  54. package/src/engine/db-adapter.ts +682 -0
  55. package/src/engine/db-schema.ts +335 -0
  56. package/src/engine/deployer.ts +595 -0
  57. package/src/engine/index.ts +134 -0
  58. package/src/engine/knowledge.ts +486 -0
  59. package/src/engine/lifecycle.ts +635 -0
  60. package/src/engine/openclaw-hook.ts +371 -0
  61. package/src/engine/routes.ts +528 -0
  62. package/src/engine/skills.ts +473 -0
  63. package/src/engine/tenant.ts +345 -0
  64. package/src/engine/tool-catalog.ts +189 -0
  65. package/src/index.ts +64 -0
  66. package/src/lib/resilience.ts +326 -0
  67. package/src/middleware/index.ts +286 -0
  68. package/src/server.ts +310 -0
  69. package/tsconfig.json +14 -0
@@ -0,0 +1,513 @@
1
+ // AgenticMail Enterprise Dashboard — Go Edition
2
+ //
3
+ // ZERO dependencies beyond the standard library. No frameworks.
4
+ //
5
+ // Setup:
6
+ // go run main.go
7
+ //
8
+ // Or:
9
+ // AGENTICMAIL_URL=https://your-company.agenticmail.cloud go run main.go
10
+
11
+ package main
12
+
13
+ import (
14
+ "bytes"
15
+ "encoding/json"
16
+ "fmt"
17
+ "html"
18
+ "html/template"
19
+ "io"
20
+ "log"
21
+ "net/http"
22
+ "os"
23
+ "strings"
24
+ "sync"
25
+ "time"
26
+ )
27
+
28
+ var apiURL = "http://localhost:3000"
29
+
30
+ // ─── Session Store (in-memory) ──────────────────────────
31
+
32
+ type Session struct {
33
+ Token string
34
+ User map[string]interface{}
35
+ }
36
+
37
+ var (
38
+ sessions = map[string]*Session{}
39
+ sessMu sync.RWMutex
40
+ )
41
+
42
+ func getSession(r *http.Request) *Session {
43
+ c, err := r.Cookie("am_session")
44
+ if err != nil {
45
+ return nil
46
+ }
47
+ sessMu.RLock()
48
+ defer sessMu.RUnlock()
49
+ return sessions[c.Value]
50
+ }
51
+
52
+ func setSession(w http.ResponseWriter, s *Session) string {
53
+ id := fmt.Sprintf("%d", time.Now().UnixNano())
54
+ sessMu.Lock()
55
+ sessions[id] = s
56
+ sessMu.Unlock()
57
+ http.SetCookie(w, &http.Cookie{Name: "am_session", Value: id, Path: "/", HttpOnly: true, MaxAge: 86400})
58
+ return id
59
+ }
60
+
61
+ func clearSession(w http.ResponseWriter, r *http.Request) {
62
+ c, err := r.Cookie("am_session")
63
+ if err == nil {
64
+ sessMu.Lock()
65
+ delete(sessions, c.Value)
66
+ sessMu.Unlock()
67
+ }
68
+ http.SetCookie(w, &http.Cookie{Name: "am_session", Value: "", Path: "/", MaxAge: -1})
69
+ }
70
+
71
+ // ─── API Client ─────────────────────────────────────────
72
+
73
+ func apiCall(path, method, token string, body interface{}) (map[string]interface{}, error) {
74
+ var reqBody io.Reader
75
+ if body != nil {
76
+ b, _ := json.Marshal(body)
77
+ reqBody = bytes.NewReader(b)
78
+ }
79
+ req, err := http.NewRequest(method, apiURL+path, reqBody)
80
+ if err != nil {
81
+ return nil, err
82
+ }
83
+ req.Header.Set("Content-Type", "application/json")
84
+ if token != "" {
85
+ req.Header.Set("Authorization", "Bearer "+token)
86
+ }
87
+ client := &http.Client{Timeout: 10 * time.Second}
88
+ resp, err := client.Do(req)
89
+ if err != nil {
90
+ return nil, err
91
+ }
92
+ defer resp.Body.Close()
93
+ var result map[string]interface{}
94
+ json.NewDecoder(resp.Body).Decode(&result)
95
+ return result, nil
96
+ }
97
+
98
+ // ─── Helpers ────────────────────────────────────────────
99
+
100
+ func esc(s interface{}) string {
101
+ if s == nil {
102
+ return ""
103
+ }
104
+ return html.EscapeString(fmt.Sprintf("%v", s))
105
+ }
106
+
107
+ func badge(status string) string {
108
+ colors := map[string]string{
109
+ "active": "#22c55e", "archived": "#888", "suspended": "#ef4444",
110
+ "owner": "#f59e0b", "admin": "#6366f1", "member": "#888", "viewer": "#555",
111
+ }
112
+ c := colors[status]
113
+ if c == "" {
114
+ c = "#888"
115
+ }
116
+ return fmt.Sprintf(`<span style="display:inline-block;padding:2px 10px;border-radius:999px;font-size:11px;font-weight:600;background:%s20;color:%s">%s</span>`, c, c, esc(status))
117
+ }
118
+
119
+ func intVal(m map[string]interface{}, key string) int {
120
+ if v, ok := m[key]; ok {
121
+ switch n := v.(type) {
122
+ case float64:
123
+ return int(n)
124
+ case int:
125
+ return n
126
+ }
127
+ }
128
+ return 0
129
+ }
130
+
131
+ func strVal(m map[string]interface{}, key string) string {
132
+ if v, ok := m[key]; ok && v != nil {
133
+ return fmt.Sprintf("%v", v)
134
+ }
135
+ return ""
136
+ }
137
+
138
+ // ─── Layout ─────────────────────────────────────────────
139
+
140
+ func layout(page string, user map[string]interface{}, content string) string {
141
+ navItem := func(href, icon, label, key string) string {
142
+ cls := ""
143
+ if page == key {
144
+ cls = " on"
145
+ }
146
+ return fmt.Sprintf(`<a href="%s" class="%s">%s <span>%s</span></a>`, href, cls, icon, label)
147
+ }
148
+
149
+ userName := ""
150
+ userEmail := ""
151
+ if user != nil {
152
+ userName = strVal(user, "name")
153
+ userEmail = strVal(user, "email")
154
+ }
155
+
156
+ return fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
157
+ <title>AgenticMail Enterprise — Go</title>
158
+ <style>*{box-sizing:border-box;margin:0;padding:0}:root{--bg:#0a0a0f;--surface:#12121a;--border:#1e1e2e;--text:#e4e4ef;--dim:#8888a0;--muted:#55556a;--primary:#6366f1;--success:#22c55e;--danger:#ef4444;--warning:#f59e0b}body{font-family:-apple-system,sans-serif;background:var(--bg);color:var(--text)}.layout{display:flex;min-height:100vh}.sidebar{width:240px;background:var(--surface);border-right:1px solid var(--border);position:fixed;top:0;left:0;bottom:0;display:flex;flex-direction:column}.sh{padding:20px;border-bottom:1px solid var(--border)}.sh h2{font-size:16px}.sh h2 em{font-style:normal;color:var(--primary)}.sh small{font-size:11px;color:var(--muted);display:block;margin-top:2px}.nav{flex:1;padding:8px 0}.ns{font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);padding:12px 20px 4px}.nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--dim);text-decoration:none;font-size:13px}.nav a:hover{color:var(--text);background:rgba(255,255,255,0.03)}.nav a.on{color:var(--primary);background:rgba(99,102,241,0.12);border-right:2px solid var(--primary)}.sf{padding:16px 20px;border-top:1px solid var(--border);font-size:12px}.content{flex:1;margin-left:240px;padding:32px;max-width:1100px}h2.t{font-size:22px;font-weight:700;margin-bottom:4px}.desc{font-size:13px;color:var(--dim);margin-bottom:24px}.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:24px}.stat{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px}.stat .l{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em}.stat .v{font-size:30px;font-weight:700;margin-top:4px}.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}.ct{font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:12px}table{width:100%%;border-collapse:collapse;font-size:13px}th{text-align:left;padding:10px 12px;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid var(--border)}td{padding:12px;border-bottom:1px solid var(--border)}tr:hover td{background:rgba(255,255,255,0.015)}.btn{display:inline-flex;align-items:center;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--text);text-decoration:none}.btn:hover{background:rgba(255,255,255,0.05)}.btn-p{background:var(--primary);border-color:var(--primary);color:#fff}.btn-d{color:var(--danger);border-color:var(--danger)}.btn-sm{padding:4px 10px;font-size:12px}.input{width:100%%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px}.fg{margin-bottom:14px}.fl{display:block;font-size:12px;color:var(--dim);margin-bottom:4px}.empty{text-align:center;padding:48px 20px;color:var(--muted)}.empty-i{font-size:36px;margin-bottom:10px}select.input{appearance:auto}@media(max-width:768px){.sidebar{width:56px}.sh h2,.sh small,.nav a span,.ns,.sf{display:none}.nav a{justify-content:center;padding:14px 0;font-size:18px}.content{margin-left:56px;padding:16px}}</style></head>
159
+ <body><div class="layout">
160
+ <div class="sidebar"><div class="sh"><h2>🏢 <em>Agentic</em>Mail</h2><small>Enterprise · Go</small></div>
161
+ <div class="nav"><div class="ns">Overview</div>%s
162
+ <div class="ns">Manage</div>%s%s%s
163
+ <div class="ns">System</div>%s%s</div>
164
+ <div class="sf"><div style="color:var(--dim)">%s</div><div style="color:var(--muted);font-size:11px">%s</div><a href="/logout" style="color:var(--muted);font-size:11px;margin-top:6px;display:inline-block">Sign out</a></div></div>
165
+ <div class="content">%s</div></div></body></html>`,
166
+ navItem("/", "📊", "Dashboard", "dashboard"),
167
+ navItem("/agents", "🤖", "Agents", "agents"),
168
+ navItem("/users", "👥", "Users", "users"),
169
+ navItem("/api-keys", "🔑", "API Keys", "keys"),
170
+ navItem("/audit", "📋", "Audit Log", "audit"),
171
+ navItem("/settings", "⚙️", "Settings", "settings"),
172
+ esc(userName), esc(userEmail), content)
173
+ }
174
+
175
+ func loginPage() string {
176
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>AgenticMail Enterprise</title>
177
+ <style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,sans-serif;background:#0a0a0f;color:#e4e4ef;display:flex;align-items:center;justify-content:center;min-height:100vh}.box{width:380px}h1{text-align:center;font-size:22px;margin-bottom:4px}h1 em{font-style:normal;color:#6366f1}.sub{text-align:center;color:#8888a0;font-size:13px;margin-bottom:32px}.fg{margin-bottom:14px}.fl{display:block;font-size:12px;color:#8888a0;margin-bottom:4px}.input{width:100%;padding:10px 14px;background:#12121a;border:1px solid #1e1e2e;border-radius:8px;color:#e4e4ef;font-size:14px;outline:none}.input:focus{border-color:#6366f1}.btn{width:100%;padding:10px;background:#6366f1;border:none;border-radius:8px;color:#fff;font-size:14px;font-weight:600;cursor:pointer}.btn:hover{background:#818cf8}</style></head>
178
+ <body><div class="box"><h1>🏢 <em>AgenticMail</em> Enterprise</h1><p class="sub">Sign in · Go Dashboard</p>
179
+ <form method="POST" action="/login"><div class="fg"><label class="fl">Email</label><input class="input" type="email" name="email" required autofocus></div>
180
+ <div class="fg"><label class="fl">Password</label><input class="input" type="password" name="password" required></div>
181
+ <button class="btn" type="submit">Sign In</button></form></div></body></html>`
182
+ }
183
+
184
+ // ─── Handlers ───────────────────────────────────────────
185
+
186
+ func requireAuth(next http.HandlerFunc) http.HandlerFunc {
187
+ return func(w http.ResponseWriter, r *http.Request) {
188
+ if getSession(r) == nil {
189
+ http.Redirect(w, r, "/login", http.StatusFound)
190
+ return
191
+ }
192
+ next(w, r)
193
+ }
194
+ }
195
+
196
+ func handleLogin(w http.ResponseWriter, r *http.Request) {
197
+ if r.Method == "GET" {
198
+ w.Header().Set("Content-Type", "text/html")
199
+ fmt.Fprint(w, loginPage())
200
+ return
201
+ }
202
+ r.ParseForm()
203
+ data, err := apiCall("/auth/login", "POST", "", map[string]string{
204
+ "email": r.FormValue("email"), "password": r.FormValue("password"),
205
+ })
206
+ if err != nil || data["token"] == nil {
207
+ errMsg := "Login failed"
208
+ if data != nil && data["error"] != nil {
209
+ errMsg = fmt.Sprintf("%v", data["error"])
210
+ }
211
+ w.Header().Set("Content-Type", "text/html")
212
+ fmt.Fprintf(w, `<html><body style="background:#0a0a0f;color:#ef4444;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh"><div>%s <a href="/login" style="color:#6366f1">Try again</a></div></body></html>`, esc(errMsg))
213
+ return
214
+ }
215
+ user, _ := data["user"].(map[string]interface{})
216
+ setSession(w, &Session{Token: fmt.Sprintf("%v", data["token"]), User: user})
217
+ http.Redirect(w, r, "/", http.StatusFound)
218
+ }
219
+
220
+ func handleLogout(w http.ResponseWriter, r *http.Request) {
221
+ clearSession(w, r)
222
+ http.Redirect(w, r, "/login", http.StatusFound)
223
+ }
224
+
225
+ func handleDashboard(w http.ResponseWriter, r *http.Request) {
226
+ s := getSession(r)
227
+ stats, _ := apiCall("/api/stats", "GET", s.Token, nil)
228
+ audit, _ := apiCall("/api/audit?limit=8", "GET", s.Token, nil)
229
+
230
+ var eventsHTML string
231
+ if events, ok := audit["events"].([]interface{}); ok && len(events) > 0 {
232
+ for _, ev := range events {
233
+ e := ev.(map[string]interface{})
234
+ eventsHTML += fmt.Sprintf(`<div style="padding:10px 0;border-bottom:1px solid var(--border);font-size:13px"><span style="color:var(--primary);font-weight:500">%s</span> on %s<div style="font-size:11px;color:var(--muted)">%s</div></div>`,
235
+ esc(e["action"]), esc(e["resource"]), esc(e["timestamp"]))
236
+ }
237
+ } else {
238
+ eventsHTML = `<div class="empty"><div class="empty-i">📋</div>No activity yet</div>`
239
+ }
240
+
241
+ if stats == nil {
242
+ stats = map[string]interface{}{}
243
+ }
244
+
245
+ content := fmt.Sprintf(`<h2 class="t">Dashboard</h2><p class="desc">Overview of your AgenticMail instance</p>
246
+ <div class="stats">
247
+ <div class="stat"><div class="l">Total Agents</div><div class="v" style="color:var(--primary)">%d</div></div>
248
+ <div class="stat"><div class="l">Active Agents</div><div class="v" style="color:var(--success)">%d</div></div>
249
+ <div class="stat"><div class="l">Users</div><div class="v">%d</div></div>
250
+ <div class="stat"><div class="l">Audit Events</div><div class="v">%d</div></div></div>
251
+ <div class="card"><div class="ct">Recent Activity</div>%s</div>`,
252
+ intVal(stats, "totalAgents"), intVal(stats, "activeAgents"),
253
+ intVal(stats, "totalUsers"), intVal(stats, "totalAuditEvents"), eventsHTML)
254
+
255
+ w.Header().Set("Content-Type", "text/html")
256
+ fmt.Fprint(w, layout("dashboard", s.User, content))
257
+ }
258
+
259
+ func handleAgents(w http.ResponseWriter, r *http.Request) {
260
+ s := getSession(r)
261
+
262
+ // Handle archive
263
+ if strings.Contains(r.URL.Path, "/archive") {
264
+ parts := strings.Split(r.URL.Path, "/")
265
+ if len(parts) >= 3 {
266
+ apiCall("/api/agents/"+parts[2]+"/archive", "POST", s.Token, nil)
267
+ }
268
+ http.Redirect(w, r, "/agents", http.StatusFound)
269
+ return
270
+ }
271
+
272
+ // Handle create
273
+ if r.Method == "POST" {
274
+ r.ParseForm()
275
+ body := map[string]string{"name": r.FormValue("name"), "role": r.FormValue("role")}
276
+ if email := r.FormValue("email"); email != "" {
277
+ body["email"] = email
278
+ }
279
+ apiCall("/api/agents", "POST", s.Token, body)
280
+ http.Redirect(w, r, "/agents", http.StatusFound)
281
+ return
282
+ }
283
+
284
+ data, _ := apiCall("/api/agents", "GET", s.Token, nil)
285
+ var tableHTML string
286
+ if agents, ok := data["agents"].([]interface{}); ok && len(agents) > 0 {
287
+ rows := ""
288
+ for _, ag := range agents {
289
+ a := ag.(map[string]interface{})
290
+ archiveBtn := ""
291
+ if strVal(a, "status") == "active" {
292
+ archiveBtn = fmt.Sprintf(`<a class="btn btn-sm btn-d" href="/agents/%s/archive">Archive</a>`, esc(a["id"]))
293
+ }
294
+ rows += fmt.Sprintf(`<tr><td style="font-weight:600">%s</td><td style="color:var(--dim)">%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
295
+ esc(a["name"]), esc(a["email"]), esc(a["role"]), badge(strVal(a, "status")), archiveBtn)
296
+ }
297
+ tableHTML = `<table><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th></th></tr></thead><tbody>` + rows + `</tbody></table>`
298
+ } else {
299
+ tableHTML = `<div class="empty"><div class="empty-i">🤖</div>No agents yet</div>`
300
+ }
301
+
302
+ content := fmt.Sprintf(`<h2 class="t">Agents</h2><p class="desc">Manage AI agent identities</p>
303
+ <div class="card" style="margin-bottom:16px"><div class="ct">Create Agent</div>
304
+ <form method="POST" action="/agents" style="display:flex;gap:10px;align-items:end">
305
+ <div class="fg" style="flex:1;margin:0"><label class="fl">Name</label><input class="input" name="name" required placeholder="e.g. researcher"></div>
306
+ <div class="fg" style="margin:0"><label class="fl">Role</label><select class="input" name="role"><option>assistant</option><option>researcher</option><option>writer</option><option>secretary</option></select></div>
307
+ <button class="btn btn-p" type="submit">Create</button></form></div>
308
+ <div class="card">%s</div>`, tableHTML)
309
+
310
+ w.Header().Set("Content-Type", "text/html")
311
+ fmt.Fprint(w, layout("agents", s.User, content))
312
+ }
313
+
314
+ func handleUsers(w http.ResponseWriter, r *http.Request) {
315
+ s := getSession(r)
316
+
317
+ if r.Method == "POST" {
318
+ r.ParseForm()
319
+ apiCall("/api/users", "POST", s.Token, map[string]string{
320
+ "name": r.FormValue("name"), "email": r.FormValue("email"),
321
+ "role": r.FormValue("role"), "password": r.FormValue("password"),
322
+ })
323
+ http.Redirect(w, r, "/users", http.StatusFound)
324
+ return
325
+ }
326
+
327
+ data, _ := apiCall("/api/users", "GET", s.Token, nil)
328
+ var tableHTML string
329
+ if users, ok := data["users"].([]interface{}); ok && len(users) > 0 {
330
+ rows := ""
331
+ for _, us := range users {
332
+ u := us.(map[string]interface{})
333
+ lastLogin := "Never"
334
+ if v := strVal(u, "lastLoginAt"); v != "" {
335
+ lastLogin = v
336
+ }
337
+ rows += fmt.Sprintf(`<tr><td style="font-weight:600">%s</td><td style="color:var(--dim)">%s</td><td>%s</td><td style="color:var(--muted);font-size:12px">%s</td></tr>`,
338
+ esc(u["name"]), esc(u["email"]), badge(strVal(u, "role")), esc(lastLogin))
339
+ }
340
+ tableHTML = `<table><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Last Login</th></tr></thead><tbody>` + rows + `</tbody></table>`
341
+ } else {
342
+ tableHTML = `<div class="empty"><div class="empty-i">👥</div>No users yet</div>`
343
+ }
344
+
345
+ content := fmt.Sprintf(`<h2 class="t">Users</h2><p class="desc">Manage team members</p>
346
+ <div class="card" style="margin-bottom:16px"><div class="ct">Create User</div>
347
+ <form method="POST" action="/users" style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
348
+ <div class="fg"><label class="fl">Name</label><input class="input" name="name" required></div>
349
+ <div class="fg"><label class="fl">Email</label><input class="input" type="email" name="email" required></div>
350
+ <div class="fg"><label class="fl">Role</label><select class="input" name="role"><option>member</option><option>admin</option><option>owner</option></select></div>
351
+ <div class="fg"><label class="fl">Password</label><input class="input" type="password" name="password" required minlength="8"></div>
352
+ <div><button class="btn btn-p" type="submit">Create</button></div></form></div>
353
+ <div class="card">%s</div>`, tableHTML)
354
+
355
+ w.Header().Set("Content-Type", "text/html")
356
+ fmt.Fprint(w, layout("users", s.User, content))
357
+ }
358
+
359
+ func handleAPIKeys(w http.ResponseWriter, r *http.Request) {
360
+ s := getSession(r)
361
+ data, _ := apiCall("/api/api-keys", "GET", s.Token, nil)
362
+ var tableHTML string
363
+ if keys, ok := data["keys"].([]interface{}); ok && len(keys) > 0 {
364
+ rows := ""
365
+ for _, ky := range keys {
366
+ k := ky.(map[string]interface{})
367
+ status := "active"
368
+ if revoked, ok := k["revoked"].(bool); ok && revoked {
369
+ status = "revoked"
370
+ }
371
+ lastUsed := "Never"
372
+ if v := strVal(k, "lastUsedAt"); v != "" {
373
+ lastUsed = v
374
+ }
375
+ rows += fmt.Sprintf(`<tr><td style="font-weight:600">%s</td><td><code style="font-size:12px">%s...</code></td><td style="color:var(--muted);font-size:12px">%s</td><td>%s</td></tr>`,
376
+ esc(k["name"]), esc(k["keyPrefix"]), esc(lastUsed), badge(status))
377
+ }
378
+ tableHTML = `<table><thead><tr><th>Name</th><th>Key</th><th>Last Used</th><th>Status</th></tr></thead><tbody>` + rows + `</tbody></table>`
379
+ } else {
380
+ tableHTML = `<div class="empty"><div class="empty-i">🔑</div>No API keys</div>`
381
+ }
382
+
383
+ content := fmt.Sprintf(`<h2 class="t">API Keys</h2><p class="desc">Manage programmatic access</p><div class="card">%s</div>`, tableHTML)
384
+ w.Header().Set("Content-Type", "text/html")
385
+ fmt.Fprint(w, layout("keys", s.User, content))
386
+ }
387
+
388
+ func handleAudit(w http.ResponseWriter, r *http.Request) {
389
+ s := getSession(r)
390
+ p := 0
391
+ fmt.Sscanf(r.URL.Query().Get("p"), "%d", &p)
392
+ if p < 0 {
393
+ p = 0
394
+ }
395
+
396
+ data, _ := apiCall(fmt.Sprintf("/api/audit?limit=25&offset=%d", p*25), "GET", s.Token, nil)
397
+ total := intVal(data, "total")
398
+ var tableHTML string
399
+ if events, ok := data["events"].([]interface{}); ok && len(events) > 0 {
400
+ rows := ""
401
+ for _, ev := range events {
402
+ e := ev.(map[string]interface{})
403
+ ip := strVal(e, "ip")
404
+ if ip == "" {
405
+ ip = "-"
406
+ }
407
+ rows += fmt.Sprintf(`<tr><td style="font-size:12px;color:var(--muted);white-space:nowrap">%s</td><td>%s</td><td style="color:var(--primary);font-weight:500">%s</td><td style="font-size:12px">%s</td><td style="font-size:12px;color:var(--muted)">%s</td></tr>`,
408
+ esc(e["timestamp"]), esc(e["actor"]), esc(e["action"]), esc(e["resource"]), esc(ip))
409
+ }
410
+ pages := (total + 24) / 25
411
+ nav := fmt.Sprintf(`<div style="display:flex;gap:8px;justify-content:center;margin-top:16px"><span style="padding:6px 12px;font-size:12px;color:var(--muted)">Page %d of %d</span></div>`, p+1, pages)
412
+ if p > 0 {
413
+ nav = fmt.Sprintf(`<div style="display:flex;gap:8px;justify-content:center;margin-top:16px"><a class="btn btn-sm" href="/audit?p=%d">← Prev</a><span style="padding:6px 12px;font-size:12px;color:var(--muted)">Page %d of %d</span>`, p-1, p+1, pages)
414
+ if (p+1)*25 < total {
415
+ nav += fmt.Sprintf(`<a class="btn btn-sm" href="/audit?p=%d">Next →</a>`, p+1)
416
+ }
417
+ nav += `</div>`
418
+ } else if (p+1)*25 < total {
419
+ nav = fmt.Sprintf(`<div style="display:flex;gap:8px;justify-content:center;margin-top:16px"><span style="padding:6px 12px;font-size:12px;color:var(--muted)">Page %d of %d</span><a class="btn btn-sm" href="/audit?p=%d">Next →</a></div>`, p+1, pages, p+1)
420
+ }
421
+ tableHTML = `<table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Resource</th><th>IP</th></tr></thead><tbody>` + rows + `</tbody></table>` + nav
422
+ } else {
423
+ tableHTML = `<div class="empty"><div class="empty-i">📋</div>No audit events yet</div>`
424
+ }
425
+
426
+ content := fmt.Sprintf(`<h2 class="t">Audit Log</h2><p class="desc">%d total events</p><div class="card">%s</div>`, total, tableHTML)
427
+ w.Header().Set("Content-Type", "text/html")
428
+ fmt.Fprint(w, layout("audit", s.User, content))
429
+ }
430
+
431
+ func handleSettings(w http.ResponseWriter, r *http.Request) {
432
+ s := getSession(r)
433
+
434
+ if r.Method == "POST" {
435
+ r.ParseForm()
436
+ apiCall("/api/settings", "PATCH", s.Token, map[string]string{
437
+ "name": r.FormValue("name"),
438
+ "domain": r.FormValue("domain"),
439
+ "primaryColor": r.FormValue("primaryColor"),
440
+ })
441
+ http.Redirect(w, r, "/settings", http.StatusFound)
442
+ return
443
+ }
444
+
445
+ settings, _ := apiCall("/api/settings", "GET", s.Token, nil)
446
+ retention, _ := apiCall("/api/retention", "GET", s.Token, nil)
447
+ if settings == nil {
448
+ settings = map[string]interface{}{}
449
+ }
450
+ if retention == nil {
451
+ retention = map[string]interface{}{}
452
+ }
453
+
454
+ retEnabled := "Disabled"
455
+ retColor := "var(--muted)"
456
+ if enabled, ok := retention["enabled"].(bool); ok && enabled {
457
+ retEnabled = "Enabled"
458
+ retColor = "var(--success)"
459
+ }
460
+ retDays := intVal(retention, "retainDays")
461
+ if retDays == 0 {
462
+ retDays = 365
463
+ }
464
+
465
+ content := fmt.Sprintf(`<h2 class="t">Settings</h2><p class="desc">Configure your organization</p>
466
+ <div class="card"><div class="ct">General</div>
467
+ <form method="POST" action="/settings" style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
468
+ <div class="fg"><label class="fl">Organization Name</label><input class="input" name="name" value="%s"></div>
469
+ <div class="fg"><label class="fl">Domain</label><input class="input" name="domain" value="%s" placeholder="agents.acme.com"></div>
470
+ <div class="fg"><label class="fl">Primary Color</label><input class="input" type="color" name="primaryColor" value="%s" style="height:38px;padding:4px"></div>
471
+ <div></div><div><button class="btn btn-p" type="submit">Save Settings</button></div></form></div>
472
+ <div class="card"><div class="ct">Plan</div>%s <span style="font-size:13px;color:var(--dim);margin-left:12px">Subdomain: %s.agenticmail.cloud</span></div>
473
+ <div class="card"><div class="ct">Data Retention</div><div style="font-size:13px">Status: <span style="color:%s">%s</span><br><span style="color:var(--dim)">Retain emails for %d days</span></div></div>`,
474
+ esc(settings["name"]), esc(settings["domain"]),
475
+ esc(settings["primaryColor"]),
476
+ badge(strings.ToUpper(strVal(settings, "plan"))),
477
+ esc(settings["subdomain"]),
478
+ retColor, retEnabled, retDays)
479
+
480
+ w.Header().Set("Content-Type", "text/html")
481
+ fmt.Fprint(w, layout("settings", s.User, content))
482
+ }
483
+
484
+ // ─── Main ───────────────────────────────────────────────
485
+
486
+ func main() {
487
+ if url := os.Getenv("AGENTICMAIL_URL"); url != "" {
488
+ apiURL = url
489
+ }
490
+
491
+ _ = template.New("") // ensure html/template is used
492
+
493
+ http.HandleFunc("/login", handleLogin)
494
+ http.HandleFunc("/logout", handleLogout)
495
+ http.HandleFunc("/", requireAuth(handleDashboard))
496
+ http.HandleFunc("/agents", requireAuth(handleAgents))
497
+ http.HandleFunc("/agents/", requireAuth(handleAgents))
498
+ http.HandleFunc("/users", requireAuth(handleUsers))
499
+ http.HandleFunc("/api-keys", requireAuth(handleAPIKeys))
500
+ http.HandleFunc("/audit", requireAuth(handleAudit))
501
+ http.HandleFunc("/settings", requireAuth(handleSettings))
502
+
503
+ port := os.Getenv("PORT")
504
+ if port == "" {
505
+ port = "8080"
506
+ }
507
+
508
+ fmt.Printf("\n🏢 AgenticMail Enterprise Dashboard (Go)\n")
509
+ fmt.Printf(" API: %s\n", apiURL)
510
+ fmt.Printf(" Dashboard: http://localhost:%s\n\n", port)
511
+
512
+ log.Fatal(http.ListenAndServe(":"+port, nil))
513
+ }