diff --git a/.changeset/afraid-pigs-sit.md b/.changeset/afraid-pigs-sit.md
new file mode 100644
index 000000000..c4d6ca905
--- /dev/null
+++ b/.changeset/afraid-pigs-sit.md
@@ -0,0 +1,8 @@
+---
+---
+
+Add member welcome and user signup email notifications via Resend.
+
+- New member thank you email sent after Stripe subscription created
+- User signup email with conditional content based on org subscription status
+- Updated naming from "Alliance for Agentic Advertising" to "AgenticAdvertising.org"
diff --git a/.env.local.example b/.env.local.example
index 676f2c0ac..127d6a2f7 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -129,6 +129,15 @@ WORKOS_REDIRECT_URI=http://localhost:3000/auth/callback
# - users:read (list users)
# - users:read.email (get user emails for auto-mapping)
+# ============================================================================
+# NOTIFICATIONS - Email (Optional)
+# ============================================================================
+
+# Resend API Key for transactional emails (welcome emails, etc.)
+# Get this from: https://resend.com/api-keys
+# Sends: welcome email to new members after subscription
+# RESEND_API_KEY=re_...
+
# ============================================================================
# PRODUCTION DEPLOYMENT NOTES
# ============================================================================
diff --git a/CLAUDE.md b/CLAUDE.md
index 95d14eac3..5877c4a63 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,6 +81,22 @@ Implementation details can be mentioned as:
- Write for an audience implementing the protocol, not using a specific implementation
- Keep examples generic and illustrative
+### Organization Naming - CRITICAL
+
+**The organization name is "AgenticAdvertising.org" - NOT "Alliance for Agentic Advertising" or "AAO".**
+
+**Rules:**
+- ✅ Use **AgenticAdvertising.org** in all user-facing content (emails, UI, documentation)
+- ✅ Use **agenticadvertising.org** for URLs and domain references
+- ❌ DO NOT use "Alliance for Agentic Advertising"
+- ❌ DO NOT use "AAO" or "AAO Team" (use "AgenticAdvertising.org Team")
+- ❌ DO NOT use adcontextprotocol.org as the organization name (that's the protocol docs site)
+
+**Why this matters:**
+- AgenticAdvertising.org is the member organization/community
+- AdCP (Ad Context Protocol) is the technical protocol specification
+- These are related but distinct - members join AgenticAdvertising.org, they use AdCP
+
### Schema Compliance - CRITICAL RULE
**🚨 ABSOLUTE REQUIREMENT: All documentation, code examples, and API usage MUST match the current JSON schemas exactly.**
diff --git a/package-lock.json b/package-lock.json
index 1ef67d8e4..d1caea8d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"pg": "^8.16.3",
"pino": "^9.14.0",
"pino-pretty": "^13.1.3",
+ "resend": "^6.6.0",
"stripe": "^20.0.0"
},
"devDependencies": {
@@ -6744,6 +6745,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -11169,6 +11176,12 @@
"benchmarks"
]
},
+ "node_modules/es6-promise": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
+ "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
+ "license": "MIT"
+ },
"node_modules/esast-util-from-estree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz",
@@ -11753,6 +11766,12 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -18641,6 +18660,12 @@
],
"license": "MIT"
},
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -19415,6 +19440,32 @@
"node": ">=0.10.0"
}
},
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "license": "MIT"
+ },
+ "node_modules/resend": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/resend/-/resend-6.6.0.tgz",
+ "integrity": "sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==",
+ "license": "MIT",
+ "dependencies": {
+ "svix": "1.76.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@react-email/render": "*"
+ },
+ "peerDependenciesMeta": {
+ "@react-email/render": {
+ "optional": true
+ }
+ }
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -20993,6 +21044,42 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/svix": {
+ "version": "1.76.1",
+ "resolved": "https://registry.npmjs.org/svix/-/svix-1.76.1.tgz",
+ "integrity": "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "@types/node": "^22.7.5",
+ "es6-promise": "^4.2.8",
+ "fast-sha256": "^1.3.0",
+ "url-parse": "^1.5.10",
+ "uuid": "^10.0.0"
+ }
+ },
+ "node_modules/svix/node_modules/@types/node": {
+ "version": "22.19.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
+ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/svix/node_modules/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
@@ -22144,6 +22231,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
"node_modules/urlpattern-polyfill": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
diff --git a/package.json b/package.json
index 0ec58a954..233bfb7f1 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"pg": "^8.16.3",
"pino": "^9.14.0",
"pino-pretty": "^13.1.3",
+ "resend": "^6.6.0",
"stripe": "^20.0.0"
},
"devDependencies": {
diff --git a/server/public/admin-agreements.html b/server/public/admin-agreements.html
index 2374bdce1..78b387861 100644
--- a/server/public/admin-agreements.html
+++ b/server/public/admin-agreements.html
@@ -5,8 +5,9 @@
Admin - Agreements - AdCP Registry
-
+
+
-
+
+
Loading...
diff --git a/server/public/admin-analytics.html b/server/public/admin-analytics.html
index 3f1796850..b11f5186f 100644
--- a/server/public/admin-analytics.html
+++ b/server/public/admin-analytics.html
@@ -5,8 +5,9 @@
Analytics - AdCP Admin
-
+
+
-
+
+
Analytics Dashboard
diff --git a/server/public/admin-audit.html b/server/public/admin-audit.html
index abec6bddc..d74fd03a6 100644
--- a/server/public/admin-audit.html
+++ b/server/public/admin-audit.html
@@ -5,8 +5,9 @@
Audit Log - AdCP Admin
-
+
+
-
+
+
Audit Log
diff --git a/server/public/admin-email.html b/server/public/admin-email.html
new file mode 100644
index 000000000..912ec9cb6
--- /dev/null
+++ b/server/public/admin-email.html
@@ -0,0 +1,632 @@
+
+
+
+
+
+
+
Email Management - AdCP Admin
+
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
Email Management
+
+
+
+
+
Total Sent
+
-
+
All time
+
+
+
Open Rate
+
-
+
Average
+
+
+
Click Rate
+
-
+
Average
+
+
+
Campaigns
+
-
+
Total
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📬
+
No campaigns yet. Create your first campaign to start engaging with members.
+
+
+
+
+
+
+
+
+
Email Templates
+
+ These are the system email templates. Edit with care - changes affect all new emails sent.
+
+
+
+
+
+ | Template |
+ Type |
+ Last Edited |
+ Actions |
+
+
+
+
+ | Loading templates... |
+
+
+
+
+
+
+
+
+
+
+
Email Categories
+
+ Users can subscribe or unsubscribe from these categories. Transactional emails are always sent.
+
+
+
Loading categories...
+
+
+
+
+
+
+
+
Recent Email Sends
+
+
+
+
+ | Recipient |
+ Type |
+ Subject |
+ Sent |
+ Status |
+
+
+
+
+ | Loading... |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/public/admin-members.html b/server/public/admin-members.html
index cfe12196e..6d799a009 100644
--- a/server/public/admin-members.html
+++ b/server/public/admin-members.html
@@ -5,8 +5,9 @@
Admin - Members - AdCP Registry
-
+
+
-
+
+
Loading members...
diff --git a/server/public/admin-org-detail.html b/server/public/admin-org-detail.html
index 34346e8b2..2066eecca 100644
--- a/server/public/admin-org-detail.html
+++ b/server/public/admin-org-detail.html
@@ -5,8 +5,9 @@
Organization Details - AdCP Registry
-
+
+
-
+
+
Loading organization details...
diff --git a/server/public/admin-perspectives.html b/server/public/admin-perspectives.html
index d10efe076..f2d88c1dc 100644
--- a/server/public/admin-perspectives.html
+++ b/server/public/admin-perspectives.html
@@ -5,8 +5,9 @@
Admin - Perspectives - AdCP Registry
-
+
+
-
+
+
Loading...
diff --git a/server/public/admin-prospects.html b/server/public/admin-prospects.html
index 7c72b5db8..5ea96760c 100644
--- a/server/public/admin-prospects.html
+++ b/server/public/admin-prospects.html
@@ -5,8 +5,9 @@
Admin - Account Management - AdCP Registry
-
+
+
-
+
+
Loading accounts...
diff --git a/server/public/admin-sidebar.js b/server/public/admin-sidebar.js
new file mode 100644
index 000000000..d1dbae632
--- /dev/null
+++ b/server/public/admin-sidebar.js
@@ -0,0 +1,369 @@
+// Shared admin sidebar navigation component
+// Include this in any admin page with:
+
+(function() {
+ 'use strict';
+
+ // Navigation configuration
+ const NAV_CONFIG = {
+ logo: 'Admin',
+ sections: [
+ {
+ label: 'Overview',
+ items: [
+ { href: '/admin', label: 'Dashboard', icon: '📊' },
+ ]
+ },
+ {
+ label: 'Members',
+ items: [
+ { href: '/admin/prospects', label: 'Prospects', icon: '🎯' },
+ { href: '/admin/members', label: 'Members', icon: '🏢' },
+ { href: '/admin/users', label: 'Users', icon: '👤' },
+ ]
+ },
+ {
+ label: 'Community',
+ items: [
+ { href: '/admin/working-groups', label: 'Working Groups', icon: '🏛️' },
+ { href: '/admin/perspectives', label: 'Perspectives', icon: '💡' },
+ ]
+ },
+ {
+ label: 'System',
+ items: [
+ { href: '/admin/agreements', label: 'Agreements', icon: '📋' },
+ { href: '/admin/email', label: 'Email', icon: '📧' },
+ { href: '/admin/analytics', label: 'Analytics', icon: '📈' },
+ { href: '/admin/audit', label: 'Audit Log', icon: '📜' },
+ ]
+ }
+ ]
+ };
+
+ // Sidebar styles
+ const SIDEBAR_STYLES = `
+ .admin-layout {
+ display: flex;
+ min-height: 100vh;
+ padding-top: 60px; /* Space for top nav */
+ }
+
+ .admin-sidebar {
+ width: 260px;
+ background: var(--color-bg-card);
+ border-right: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ top: 60px; /* Below top nav */
+ left: 0;
+ bottom: 0;
+ z-index: 100;
+ transition: transform 0.3s ease;
+ }
+
+ .admin-sidebar-header {
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-primary-600) 100%);
+ }
+
+ .admin-sidebar-logo {
+ font-size: 18px;
+ font-weight: 600;
+ color: white;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .admin-sidebar-logo img {
+ width: 28px;
+ height: 28px;
+ }
+
+ .admin-sidebar-nav {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px 0;
+ }
+
+ .admin-nav-section {
+ margin-bottom: 8px;
+ }
+
+ .admin-nav-section-label {
+ padding: 8px 24px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--color-text-muted);
+ }
+
+ .admin-nav-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 24px;
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ font-size: 14px;
+ transition: all 0.15s ease;
+ border-left: 3px solid transparent;
+ }
+
+ .admin-nav-item:hover {
+ background: var(--color-bg-subtle);
+ color: var(--color-text-heading);
+ }
+
+ .admin-nav-item.active {
+ background: var(--color-primary-50);
+ color: var(--color-brand);
+ border-left-color: var(--color-brand);
+ font-weight: 500;
+ }
+
+ .admin-nav-icon {
+ font-size: 16px;
+ width: 20px;
+ text-align: center;
+ }
+
+ .admin-sidebar-footer {
+ padding: 16px 24px;
+ border-top: 1px solid var(--color-border);
+ }
+
+ .admin-back-link {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ font-size: 13px;
+ padding: 8px 0;
+ transition: color 0.15s;
+ }
+
+ .admin-back-link:hover {
+ color: var(--color-brand);
+ }
+
+ .admin-main {
+ flex: 1;
+ margin-left: 260px;
+ min-height: 100vh;
+ background: var(--color-bg-page);
+ }
+
+ /* Mobile sidebar toggle */
+ .admin-sidebar-toggle {
+ display: none;
+ position: fixed;
+ top: 76px;
+ left: 16px;
+ z-index: 101;
+ background: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ padding: 8px 12px;
+ cursor: pointer;
+ font-size: 20px;
+ }
+
+ .admin-sidebar-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 99;
+ }
+
+ /* Mobile responsive */
+ @media (max-width: 768px) {
+ .admin-sidebar {
+ transform: translateX(-100%);
+ }
+
+ .admin-sidebar.open {
+ transform: translateX(0);
+ }
+
+ .admin-sidebar-toggle {
+ display: block;
+ }
+
+ .admin-sidebar-overlay.show {
+ display: block;
+ }
+
+ .admin-main {
+ margin-left: 0;
+ }
+ }
+ `;
+
+ // Inject styles
+ function injectStyles() {
+ if (document.getElementById('admin-sidebar-styles')) return;
+
+ const styleEl = document.createElement('style');
+ styleEl.id = 'admin-sidebar-styles';
+ styleEl.textContent = SIDEBAR_STYLES;
+ document.head.appendChild(styleEl);
+ }
+
+ // Create sidebar HTML
+ function createSidebarHTML() {
+ const currentPath = window.location.pathname;
+
+ const sectionsHTML = NAV_CONFIG.sections.map(section => {
+ const itemsHTML = section.items.map(item => {
+ const isActive = currentPath === item.href ||
+ (item.href !== '/admin' && currentPath.startsWith(item.href));
+ const activeClass = isActive ? 'active' : '';
+ return `
+
+ ${item.icon}
+ ${item.label}
+
+ `;
+ }).join('');
+
+ return `
+
+
${section.label}
+ ${itemsHTML}
+
+ `;
+ }).join('');
+
+ return `
+
+
+
+ `;
+ }
+
+ // Wrap content in main container
+ function wrapContent() {
+ // Find existing content wrapper or body content
+ const existingMain = document.querySelector('.admin-main');
+ if (existingMain) return; // Already wrapped
+
+ // Get all body children except scripts and the sidebar
+ const bodyChildren = Array.from(document.body.children).filter(el =>
+ el.tagName !== 'SCRIPT' &&
+ !el.classList.contains('admin-sidebar') &&
+ !el.classList.contains('admin-sidebar-toggle') &&
+ !el.classList.contains('admin-sidebar-overlay') &&
+ el.id !== 'adcp-nav'
+ );
+
+ // Create main wrapper
+ const mainWrapper = document.createElement('main');
+ mainWrapper.className = 'admin-main';
+
+ // Move children to wrapper
+ bodyChildren.forEach(child => {
+ mainWrapper.appendChild(child);
+ });
+
+ // Add wrapper to body
+ document.body.appendChild(mainWrapper);
+ }
+
+ // Initialize navigation
+ function init() {
+ injectStyles();
+
+ // Remove old header nav if present
+ const oldHeader = document.querySelector('.admin-header');
+ if (oldHeader) {
+ oldHeader.remove();
+ }
+
+ // Insert sidebar at start of body (after adcp-nav if present)
+ const sidebarHTML = createSidebarHTML();
+ const adcpNav = document.getElementById('adcp-nav');
+ if (adcpNav) {
+ adcpNav.insertAdjacentHTML('afterend', sidebarHTML);
+ } else {
+ document.body.insertAdjacentHTML('afterbegin', sidebarHTML);
+ }
+
+ // Add layout class to body
+ document.body.classList.add('admin-layout');
+
+ // Wrap existing content
+ wrapContent();
+ }
+
+ // Toggle sidebar (mobile)
+ function toggleSidebar() {
+ const sidebar = document.getElementById('adminSidebar');
+ const overlay = document.querySelector('.admin-sidebar-overlay');
+ sidebar?.classList.toggle('open');
+ overlay?.classList.toggle('show');
+ }
+
+ function closeSidebar() {
+ const sidebar = document.getElementById('adminSidebar');
+ const overlay = document.querySelector('.admin-sidebar-overlay');
+ sidebar?.classList.remove('open');
+ overlay?.classList.remove('show');
+ }
+
+ // Utility function to redirect to login with return_to parameter
+ function redirectToLogin() {
+ const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
+ window.location.href = `/auth/login?return_to=${returnUrl}`;
+ }
+
+ // Auto-initialize only on admin pages
+ function shouldInitialize() {
+ return window.location.pathname.startsWith('/admin');
+ }
+
+ if (shouldInitialize()) {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+ }
+
+ // Export API
+ window.AdminSidebar = {
+ config: NAV_CONFIG,
+ init,
+ toggleSidebar,
+ closeSidebar,
+ redirectToLogin
+ };
+})();
diff --git a/server/public/admin-users.html b/server/public/admin-users.html
index 9cf2f0113..e30fca138 100644
--- a/server/public/admin-users.html
+++ b/server/public/admin-users.html
@@ -5,8 +5,9 @@
Admin - Users - AdCP Registry
-
+
+
+
+
+
Loading...
diff --git a/server/public/admin-working-groups.html b/server/public/admin-working-groups.html
index a3295e5bf..b4e930541 100644
--- a/server/public/admin-working-groups.html
+++ b/server/public/admin-working-groups.html
@@ -5,8 +5,9 @@
Admin - Working Groups - AdCP Registry
-
+
+
+
+
+
Loading...
diff --git a/server/public/admin.html b/server/public/admin.html
index 1f7d2b8eb..35d3cc063 100644
--- a/server/public/admin.html
+++ b/server/public/admin.html
@@ -5,8 +5,9 @@
Admin - AdCP Registry
-
+
+
-
+
+
diff --git a/server/public/dashboard-billing.html b/server/public/dashboard-billing.html
new file mode 100644
index 000000000..629a43661
--- /dev/null
+++ b/server/public/dashboard-billing.html
@@ -0,0 +1,493 @@
+
+
+
+
+
+
Billing - Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading billing information...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading agreement...
+
+
+
+
+
+
+
+
+ Please accept the membership agreement above to continue
+
+
+
+
+
+
+
+
diff --git a/server/public/dashboard-emails.html b/server/public/dashboard-emails.html
new file mode 100644
index 000000000..f61ac18ad
--- /dev/null
+++ b/server/public/dashboard-emails.html
@@ -0,0 +1,422 @@
+
+
+
+
+
+
Email Preferences - Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading preferences...
+
+
+
+
+
+
You're unsubscribed from all emails
+
You won't receive any marketing or community emails. Transactional emails (password resets, billing) will still be sent.
+
+
+
+
+
+
+
+
+
+
+
+
+ Some emails can't be unsubscribed from because they're essential for your account. These include:
+
+
+ - Welcome emails when you sign up
+ - Password reset and security emails
+ - Billing receipts and payment issues
+ - Important account notifications
+
+
+
+
+
+
+
+
+
diff --git a/server/public/dashboard-nav.js b/server/public/dashboard-nav.js
new file mode 100644
index 000000000..dadf9cd52
--- /dev/null
+++ b/server/public/dashboard-nav.js
@@ -0,0 +1,506 @@
+// Shared dashboard navigation component with sidebar
+// Include this in any dashboard page with:
+
+(function() {
+ 'use strict';
+
+ // Navigation configuration
+ const NAV_CONFIG = {
+ logo: 'Dashboard',
+ sections: [
+ {
+ label: 'Overview',
+ items: [
+ { href: '/dashboard', label: 'Home', icon: '🏠' },
+ ]
+ },
+ {
+ label: 'Organization',
+ items: [
+ { href: '/member-profile', label: 'Member Profile', icon: '🏢' },
+ { href: '/team', label: 'Team', icon: '👥' },
+ { href: '/working-groups', label: 'Working Groups', icon: '🏛️' },
+ ]
+ },
+ {
+ label: 'Account',
+ items: [
+ { href: '/dashboard/billing', label: 'Billing', icon: '💳' },
+ { href: '/dashboard/settings', label: 'Settings', icon: '⚙️' },
+ { href: '/dashboard/emails', label: 'Email Preferences', icon: '📧' },
+ ]
+ }
+ ],
+ backLink: { href: 'https://agenticadvertising.org', label: '← Back to AAO' }
+ };
+
+ // Sidebar styles
+ // Note: top nav is ~60px, so sidebar starts below it
+ const SIDEBAR_STYLES = `
+ .dashboard-layout {
+ display: flex;
+ min-height: 100vh;
+ padding-top: 60px; /* Space for top nav */
+ }
+
+ .dashboard-sidebar {
+ width: 260px;
+ background: var(--color-bg-card);
+ border-right: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ top: 60px; /* Below top nav */
+ left: 0;
+ bottom: 0;
+ z-index: 100;
+ transition: transform 0.3s ease;
+ }
+
+ .dashboard-sidebar-header {
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .dashboard-sidebar-logo {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--color-text-heading);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .dashboard-sidebar-logo img {
+ width: 28px;
+ height: 28px;
+ }
+
+ .dashboard-sidebar-nav {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px 0;
+ }
+
+ .dashboard-nav-section {
+ margin-bottom: 8px;
+ }
+
+ .dashboard-nav-section-label {
+ padding: 8px 24px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--color-text-muted);
+ }
+
+ .dashboard-nav-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 24px;
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ font-size: 14px;
+ transition: all 0.15s ease;
+ border-left: 3px solid transparent;
+ }
+
+ .dashboard-nav-item:hover {
+ background: var(--color-bg-subtle);
+ color: var(--color-text-heading);
+ }
+
+ .dashboard-nav-item.active {
+ background: var(--color-primary-50);
+ color: var(--color-brand);
+ border-left-color: var(--color-brand);
+ font-weight: 500;
+ }
+
+ .dashboard-nav-icon {
+ font-size: 16px;
+ width: 20px;
+ text-align: center;
+ }
+
+ .dashboard-sidebar-footer {
+ padding: 16px 24px;
+ border-top: 1px solid var(--color-border);
+ }
+
+ .dashboard-back-link {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ font-size: 13px;
+ padding: 8px 0;
+ transition: color 0.15s;
+ }
+
+ .dashboard-back-link:hover {
+ color: var(--color-brand);
+ }
+
+ .dashboard-main {
+ flex: 1;
+ margin-left: 260px;
+ min-height: 100vh;
+ background: var(--color-bg-page);
+ }
+
+ /* Mobile sidebar toggle */
+ .dashboard-sidebar-toggle {
+ display: none;
+ position: fixed;
+ top: 16px;
+ left: 16px;
+ z-index: 101;
+ background: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ padding: 8px 12px;
+ cursor: pointer;
+ font-size: 20px;
+ }
+
+ .dashboard-sidebar-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 99;
+ }
+
+ /* Mobile responsive */
+ @media (max-width: 768px) {
+ .dashboard-sidebar {
+ transform: translateX(-100%);
+ }
+
+ .dashboard-sidebar.open {
+ transform: translateX(0);
+ }
+
+ .dashboard-sidebar-toggle {
+ display: block;
+ }
+
+ .dashboard-sidebar-overlay.show {
+ display: block;
+ }
+
+ .dashboard-main {
+ margin-left: 0;
+ }
+ }
+
+ /* Org switcher in sidebar */
+ .dashboard-org-switcher {
+ padding: 12px 24px;
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ .dashboard-org-btn {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ background: var(--color-bg-subtle);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--color-text-heading);
+ transition: all 0.15s;
+ }
+
+ .dashboard-org-btn:hover {
+ border-color: var(--color-brand);
+ }
+
+ .dashboard-org-name {
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 160px;
+ }
+
+ .dashboard-org-dropdown {
+ display: none;
+ position: absolute;
+ left: 24px;
+ right: 24px;
+ background: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ box-shadow: var(--shadow-lg);
+ z-index: 200;
+ max-height: 300px;
+ overflow-y: auto;
+ margin-top: 4px;
+ }
+
+ .dashboard-org-dropdown.show {
+ display: block;
+ }
+
+ .dashboard-org-option {
+ display: block;
+ width: 100%;
+ padding: 10px 12px;
+ text-align: left;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--color-text-secondary);
+ transition: background 0.15s;
+ }
+
+ .dashboard-org-option:hover {
+ background: var(--color-bg-subtle);
+ }
+
+ .dashboard-org-option.selected {
+ background: var(--color-primary-50);
+ color: var(--color-brand);
+ font-weight: 500;
+ }
+
+ /* Admin link in sidebar */
+ .dashboard-admin-link {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--color-brand);
+ color: white;
+ text-decoration: none;
+ font-size: 12px;
+ font-weight: 500;
+ border-radius: 6px;
+ margin-top: 12px;
+ transition: opacity 0.15s;
+ }
+
+ .dashboard-admin-link:hover {
+ opacity: 0.9;
+ }
+ `;
+
+ // Inject styles
+ function injectStyles() {
+ if (document.getElementById('dashboard-nav-styles')) return;
+
+ const styleEl = document.createElement('style');
+ styleEl.id = 'dashboard-nav-styles';
+ styleEl.textContent = SIDEBAR_STYLES;
+ document.head.appendChild(styleEl);
+ }
+
+ // Create sidebar HTML
+ function createSidebarHTML(options = {}) {
+ const currentPath = window.location.pathname;
+ const { showAdmin = false, showOrgSwitcher = false, currentOrgName = 'Select Organization' } = options;
+
+ const sectionsHTML = NAV_CONFIG.sections.map(section => {
+ const itemsHTML = section.items.map(item => {
+ const isActive = currentPath === item.href ||
+ (item.href !== '/dashboard' && currentPath.startsWith(item.href));
+ const activeClass = isActive ? 'active' : '';
+ return `
+
+ ${item.icon}
+ ${item.label}
+
+ `;
+ }).join('');
+
+ return `
+
+
${section.label}
+ ${itemsHTML}
+
+ `;
+ }).join('');
+
+ const orgSwitcherHTML = showOrgSwitcher ? `
+
+
+
+
+ ` : '';
+
+ const adminLinkHTML = showAdmin ? `
+
+ 🔒 Admin Panel
+
+ ` : '';
+
+ return `
+
+
+
+ `;
+ }
+
+ // Wrap content in main container
+ function wrapContent() {
+ // Find existing content wrapper or body content
+ const existingMain = document.querySelector('.dashboard-main');
+ if (existingMain) return; // Already wrapped
+
+ // Get all body children except scripts and the sidebar
+ const bodyChildren = Array.from(document.body.children).filter(el =>
+ el.tagName !== 'SCRIPT' &&
+ !el.classList.contains('dashboard-sidebar') &&
+ !el.classList.contains('dashboard-sidebar-toggle') &&
+ !el.classList.contains('dashboard-sidebar-overlay')
+ );
+
+ // Create main wrapper
+ const mainWrapper = document.createElement('main');
+ mainWrapper.className = 'dashboard-main';
+
+ // Move children to wrapper
+ bodyChildren.forEach(child => {
+ mainWrapper.appendChild(child);
+ });
+
+ // Add wrapper to body
+ document.body.appendChild(mainWrapper);
+ }
+
+ // Initialize navigation
+ function init(options = {}) {
+ injectStyles();
+
+ // Insert sidebar at start of body
+ const sidebarHTML = createSidebarHTML(options);
+ document.body.insertAdjacentHTML('afterbegin', sidebarHTML);
+
+ // Add layout class to body
+ document.body.classList.add('dashboard-layout');
+
+ // Wrap existing content
+ wrapContent();
+ }
+
+ // Toggle sidebar (mobile)
+ function toggleSidebar() {
+ const sidebar = document.getElementById('dashboardSidebar');
+ const overlay = document.querySelector('.dashboard-sidebar-overlay');
+ sidebar?.classList.toggle('open');
+ overlay?.classList.toggle('show');
+ }
+
+ function closeSidebar() {
+ const sidebar = document.getElementById('dashboardSidebar');
+ const overlay = document.querySelector('.dashboard-sidebar-overlay');
+ sidebar?.classList.remove('open');
+ overlay?.classList.remove('show');
+ }
+
+ // Org dropdown functions
+ function toggleOrgDropdown() {
+ const dropdown = document.getElementById('dashboardOrgDropdown');
+ dropdown?.classList.toggle('show');
+ }
+
+ function closeOrgDropdown() {
+ const dropdown = document.getElementById('dashboardOrgDropdown');
+ dropdown?.classList.remove('show');
+ }
+
+ function setOrgName(name) {
+ const el = document.getElementById('dashboardOrgName');
+ if (el) el.textContent = name;
+ }
+
+ function setOrgOptions(orgs, selectedId, onSelect) {
+ const dropdown = document.getElementById('dashboardOrgDropdown');
+ if (!dropdown) return;
+
+ dropdown.innerHTML = orgs.map(org => `
+
+ `).join('');
+
+ // Store callback
+ window._dashboardOrgSelectCallback = onSelect;
+ }
+
+ function selectOrg(orgId) {
+ closeOrgDropdown();
+ if (window._dashboardOrgSelectCallback) {
+ window._dashboardOrgSelectCallback(orgId);
+ }
+ }
+
+ // Show/hide admin link
+ function showAdminLink(show) {
+ const footer = document.querySelector('.dashboard-sidebar-footer');
+ const existingLink = footer?.querySelector('.dashboard-admin-link');
+
+ if (show && !existingLink && footer) {
+ footer.insertAdjacentHTML('beforeend', `
+
+ 🔒 Admin Panel
+
+ `);
+ } else if (!show && existingLink) {
+ existingLink.remove();
+ }
+ }
+
+ // Close dropdown when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.dashboard-org-switcher')) {
+ closeOrgDropdown();
+ }
+ });
+
+ // Export API
+ window.DashboardNav = {
+ config: NAV_CONFIG,
+ init,
+ toggleSidebar,
+ closeSidebar,
+ toggleOrgDropdown,
+ closeOrgDropdown,
+ setOrgName,
+ setOrgOptions,
+ selectOrg,
+ showAdminLink
+ };
+})();
diff --git a/server/public/dashboard-settings.html b/server/public/dashboard-settings.html
new file mode 100644
index 000000000..c21e0b36f
--- /dev/null
+++ b/server/public/dashboard-settings.html
@@ -0,0 +1,519 @@
+
+
+
+
+
+
Settings - Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Rename Organization
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete Workspace
+
+
This action cannot be undone.
+
Deleting this workspace will permanently remove:
+
+ - All team members and their access
+ - Your member profile
+ - All workspace settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading settings...
+
+
+
+
+
+
+
+
+
Organization Name
+
Loading...
+
+
+
+
+
+
+
+
Workspace Type
+
Loading...
+
+
+
+
+
+
+
+
Organization ID
+
Loading...
+
+
+
+
+
+
+
+
+
+
+
+
Delete Workspace
+
Permanently delete this workspace and all its data
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/public/dashboard.html b/server/public/dashboard.html
index f538b25d4..43267b0c5 100644
--- a/server/public/dashboard.html
+++ b/server/public/dashboard.html
@@ -18,20 +18,6 @@
background: var(--color-bg-page);
}
- .dashboard-toolbar {
- background: var(--color-bg-subtle);
- border-bottom: var(--border-1) solid var(--color-border);
- padding: var(--space-2.5) var(--space-5);
- }
-
- .dashboard-toolbar-content {
- max-width: var(--container-xl);
- margin: 0 auto;
- display: flex;
- justify-content: flex-start;
- align-items: center;
- }
-
.container {
max-width: var(--container-xl);
margin: var(--space-10) auto;
@@ -134,122 +120,6 @@
- .btn .btn-sm = smaller size
*/
- /* Admin Link - uses brand color */
- .admin-link {
- margin-left: auto;
- padding: var(--space-2) var(--space-4);
- background: var(--color-brand);
- color: white;
- text-decoration: none;
- border-radius: var(--radius-md);
- font-size: var(--text-sm);
- font-weight: var(--font-medium);
- transition: var(--transition-colors);
- }
-
- .admin-link:hover {
- background: var(--color-brand-hover);
- }
-
- /* Org Switcher */
- .org-switcher {
- position: relative;
- }
-
- .org-switcher-btn {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- padding: var(--space-2) var(--space-3);
- background: var(--color-bg-page);
- border: var(--border-1) solid var(--color-border);
- border-radius: var(--radius-md);
- cursor: pointer;
- font-size: var(--text-sm);
- color: var(--color-text-heading);
- min-width: 180px;
- transition: var(--transition-colors);
- }
-
- .org-switcher-btn:hover {
- background: var(--color-gray-200);
- }
-
- .org-switcher-btn .org-name {
- flex: 1;
- text-align: left;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .org-switcher-btn .arrow {
- font-size: var(--text-xs);
- color: var(--color-text-secondary);
- }
-
- .org-switcher-dropdown {
- display: none;
- position: absolute;
- top: 100%;
- left: 0;
- right: 0;
- margin-top: var(--space-1);
- background: var(--color-bg-card);
- border: var(--border-1) solid var(--color-border);
- border-radius: var(--radius-md);
- box-shadow: var(--shadow-lg);
- z-index: var(--z-dropdown);
- max-height: 300px;
- overflow-y: auto;
- }
-
- .org-switcher-dropdown.show {
- display: block;
- }
-
- .org-switcher-item {
- padding: var(--space-2.5) var(--space-3);
- cursor: pointer;
- border-bottom: var(--border-1) solid var(--color-gray-100);
- transition: var(--transition-colors);
- }
-
- .org-switcher-item:last-child {
- border-bottom: none;
- }
-
- .org-switcher-item:hover {
- background: var(--color-bg-subtle);
- }
-
- .org-switcher-item.selected {
- background: var(--color-info-50);
- }
-
- .org-switcher-item .item-name {
- font-weight: var(--font-medium);
- color: var(--color-text-heading);
- }
-
- .org-switcher-item .item-badge {
- display: inline-block;
- padding: var(--space-0.5) var(--space-1.5);
- font-size: var(--text-xs);
- border-radius: var(--radius-full);
- margin-left: var(--space-1.5);
- }
-
- .org-switcher-item .item-badge.active {
- background: var(--color-primary-100);
- color: var(--color-success-700);
- }
-
- .org-switcher-item .item-badge.inactive {
- background: var(--color-gray-100);
- color: var(--color-text-secondary);
- }
-
/* Responsive layout for dashboard */
@media (max-width: 900px) {
.dashboard-grid {
@@ -257,59 +127,6 @@
}
}
- /* Delete workspace styles */
- .btn-danger {
- background: #dc2626;
- color: white;
- border: none;
- padding: 10px 20px;
- border-radius: 6px;
- cursor: pointer;
- font-size: 14px;
- }
- .btn-danger:hover {
- background: #b91c1c;
- }
- .delete-warning {
- background: #fef2f2;
- border-left: 4px solid #ef4444;
- padding: 15px;
- border-radius: 6px;
- margin-bottom: 15px;
- }
- .delete-warning p {
- margin: 0 0 10px 0;
- color: #991b1b;
- }
- .delete-warning ul {
- margin: 0;
- padding-left: 20px;
- color: #991b1b;
- }
- .delete-error {
- background: #fee;
- border-left: 4px solid #c33;
- padding: 10px;
- border-radius: 4px;
- margin-top: 15px;
- color: #c33;
- }
- .confirm-input {
- width: 100%;
- padding: 10px;
- border: 1px solid #ddd;
- border-radius: 6px;
- font-size: 14px;
- margin-top: 10px;
- }
- .workspace-name-highlight {
- background: #fee;
- padding: 2px 6px;
- border-radius: 4px;
- font-family: monospace;
- font-weight: bold;
- }
-
/* Toast notification */
.toast {
position: fixed;
@@ -384,49 +201,13 @@
Rename Organization
-
-
-
-
⚠️ Delete Workspace
-
-
This action cannot be undone.
-
Deleting this workspace will permanently remove:
-
- - All team members and their access
- - Your member profile
- - All workspace settings
-
-
-
-
-
To confirm, type the workspace name:
-
-
-
-
-
-
-
-
-
-
+
-
-
-
+
+
+
@@ -525,16 +306,6 @@
-
-
-
- ⚙️ Organization Settings
-
-
-
-
@@ -544,19 +315,6 @@ Loading...
-
-
-
-
- ⚠️ Danger Zone
-
-
-
Permanently delete this workspace and all of its data.
-
-
-
@@ -590,69 +348,24 @@
- ${org.name}
- ${badgeText}
-
- `;
- }).join('');
-
- // Update selected name
- const selected = allOrganizations.find(o => o.id === selectedOrgId);
- if (selected) {
- selectedName.textContent = selected.name;
- }
+ // Re-render the selected org
+ renderSelectedOrg();
}
// Render the currently selected organization
@@ -662,12 +375,6 @@ Create Your Pro
const data = await response.json();
- // Show admin link if user is admin
- if (data.user?.isAdmin) {
- document.getElementById('adminLink').style.display = 'block';
- }
+ // Initialize sidebar navigation
+ const isAdmin = data.user?.isAdmin || false;
+ const hasMultipleOrgs = data.organizations && data.organizations.length > 1;
if (data.organizations && data.organizations.length > 0) {
// Load billing info for each organization
@@ -1541,8 +1230,20 @@ Create Your Pro
window.history.replaceState({}, '', url);
}
- // Update org switcher dropdown
- updateOrgSwitcher();
+ // Initialize sidebar with org switcher
+ const selectedOrg = orgsWithBilling.find(o => o.id === selectedOrgId) || orgsWithBilling[0];
+ DashboardNav.init({
+ showOrgSwitcher: hasMultipleOrgs,
+ currentOrgName: selectedOrg?.name || 'Dashboard',
+ showAdmin: isAdmin
+ });
+
+ // Set up org switcher in sidebar if multiple orgs
+ if (hasMultipleOrgs) {
+ DashboardNav.setOrgOptions(orgsWithBilling, selectedOrgId, (newOrgId) => {
+ selectOrg(newOrgId);
+ });
+ }
// Render the selected organization
renderSelectedOrg();
@@ -2074,110 +1775,6 @@ Create Your Pro
}
}
- // Organization settings helper labels
- const companyTypeLabels = {
- 'brand': 'Brand / Marketer',
- 'publisher': 'Publisher / Media Network',
- 'agency': 'Agency',
- 'adtech': 'Technology Provider',
- 'other': 'Other'
- };
-
- const revenueTierLabels = {
- 'under_1m': '< $1M',
- '1m_5m': '$1-5M',
- '5m_50m': '$5-50M',
- '50m_250m': '$50-250M',
- '250m_1b': '$250M-1B',
- '1b_plus': '$1B+'
- };
-
- // Render organization settings form
- function renderOrgSettings(org) {
- const container = document.getElementById('orgSettingsDetails');
- if (!container) return;
-
- // Get current values from billing data (which includes company_type, revenue_tier)
- const companyType = org.billing?.company_type || null;
- const revenueTier = org.billing?.revenue_tier || null;
-
- container.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- }
-
- // Save organization settings
- async function saveOrgSettings(orgId) {
- const companyType = document.getElementById('orgSettingsCompanyType').value || null;
- const revenueTier = document.getElementById('orgSettingsRevenueTier').value || null;
- const messageEl = document.getElementById('orgSettingsMessage');
-
- try {
- const response = await fetch(`/api/organizations/${orgId}/settings`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- company_type: companyType,
- revenue_tier: revenueTier
- }),
- credentials: 'same-origin'
- });
-
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.message || 'Failed to save settings');
- }
-
- // Update the local org data
- if (currentOrg && currentOrg.billing) {
- currentOrg.billing.company_type = companyType;
- currentOrg.billing.revenue_tier = revenueTier;
- }
-
- // Show success message
- messageEl.style.display = 'block';
- messageEl.style.background = 'var(--color-success-50)';
- messageEl.style.color = 'var(--color-success-700)';
- messageEl.textContent = 'Settings saved successfully';
-
- setTimeout(() => {
- messageEl.style.display = 'none';
- }, 3000);
- } catch (error) {
- console.error('Error saving org settings:', error);
- messageEl.style.display = 'block';
- messageEl.style.background = 'var(--color-error-50)';
- messageEl.style.color = 'var(--color-error-700)';
- messageEl.textContent = error.message;
- }
- }
-
loadUserData();
// Handle browser back/forward cache (bfcache) restoration
@@ -2192,87 +1789,6 @@ Create Your Pro
}
});
- // Delete workspace modal functions
- function openDeleteWorkspaceModal() {
- if (!currentOrg) return;
- document.getElementById('deleteWorkspaceName').textContent = currentOrg.name;
- document.getElementById('deleteConfirmInput').value = '';
- document.getElementById('deleteWorkspaceError').style.display = 'none';
- document.getElementById('confirmDeleteWorkspaceBtn').disabled = true;
- document.getElementById('confirmDeleteWorkspaceBtn').style.opacity = '0.5';
- document.getElementById('deleteWorkspaceModal').classList.add('show');
- document.getElementById('deleteConfirmInput').focus();
- }
-
- function closeDeleteWorkspaceModal() {
- document.getElementById('deleteWorkspaceModal').classList.remove('show');
- document.getElementById('deleteWorkspaceError').style.display = 'none';
- }
-
- function checkDeleteWorkspaceConfirmation() {
- if (!currentOrg) return;
- const input = document.getElementById('deleteConfirmInput').value;
- const btn = document.getElementById('confirmDeleteWorkspaceBtn');
- if (input === currentOrg.name) {
- btn.disabled = false;
- btn.style.opacity = '1';
- } else {
- btn.disabled = true;
- btn.style.opacity = '0.5';
- }
- }
-
- async function confirmDeleteWorkspace() {
- if (!currentOrg) return;
-
- const confirmation = document.getElementById('deleteConfirmInput').value;
- const errorEl = document.getElementById('deleteWorkspaceError');
- const btn = document.getElementById('confirmDeleteWorkspaceBtn');
-
- // Disable button during request
- btn.disabled = true;
- btn.textContent = 'Deleting...';
- errorEl.style.display = 'none';
-
- try {
- const response = await fetch(`/api/organizations/${currentOrg.id}`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ confirmation }),
- credentials: 'same-origin',
- });
-
- const data = await response.json();
-
- if (!response.ok) {
- // Use the API's message which is more specific about the issue
- throw new Error(data.message || data.error || 'Failed to delete workspace');
- }
-
- // Success - redirect to onboarding to show join/create options
- alert('Workspace deleted successfully.');
- localStorage.removeItem('selectedOrgId');
- window.location.href = '/onboarding';
- } catch (error) {
- errorEl.textContent = error.message;
- errorEl.style.display = 'block';
- btn.disabled = false;
- btn.textContent = 'Delete Workspace';
- }
- }
-
- // Handle Enter key in delete confirmation input
- document.addEventListener('DOMContentLoaded', () => {
- const deleteInput = document.getElementById('deleteConfirmInput');
- if (deleteInput) {
- deleteInput.addEventListener('keypress', (e) => {
- if (e.key === 'Enter' && !document.getElementById('confirmDeleteWorkspaceBtn').disabled) {
- confirmDeleteWorkspace();
- }
- });
- }
- });
-
// Toast notification helper
function showToast(message, duration = 3000) {
const toast = document.getElementById('toast');
diff --git a/server/src/db/email-db.ts b/server/src/db/email-db.ts
new file mode 100644
index 000000000..e7a9a2aaf
--- /dev/null
+++ b/server/src/db/email-db.ts
@@ -0,0 +1,282 @@
+import { query } from './client.js';
+import crypto from 'crypto';
+
+export interface EmailEvent {
+ id: string;
+ tracking_id: string;
+ email_type: string;
+ recipient_email: string;
+ subject: string | null;
+ workos_user_id: string | null;
+ workos_organization_id: string | null;
+ sent_at: Date | null;
+ resend_email_id: string | null;
+ opened_at: Date | null;
+ open_count: number;
+ first_clicked_at: Date | null;
+ click_count: number;
+ delivered_at: Date | null;
+ bounced_at: Date | null;
+ bounce_reason: string | null;
+ metadata: Record | null;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface EmailClick {
+ id: number;
+ email_event_id: string;
+ link_name: string | null;
+ destination_url: string;
+ clicked_at: Date;
+ ip_address: string | null;
+ user_agent: string | null;
+ referrer: string | null;
+ utm_source: string | null;
+ utm_medium: string | null;
+ utm_campaign: string | null;
+}
+
+/**
+ * Generate a short tracking ID for URLs
+ */
+function generateTrackingId(): string {
+ return crypto.randomBytes(12).toString('base64url');
+}
+
+/**
+ * Database operations for email tracking
+ */
+export class EmailDatabase {
+ /**
+ * Create a new email event record (before sending)
+ */
+ async createEmailEvent(data: {
+ email_type: string;
+ recipient_email: string;
+ subject?: string;
+ workos_user_id?: string;
+ workos_organization_id?: string;
+ metadata?: Record;
+ }): Promise {
+ const trackingId = generateTrackingId();
+
+ const result = await query(
+ `INSERT INTO email_events (
+ tracking_id, email_type, recipient_email, subject,
+ workos_user_id, workos_organization_id, metadata
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING *`,
+ [
+ trackingId,
+ data.email_type,
+ data.recipient_email,
+ data.subject || null,
+ data.workos_user_id || null,
+ data.workos_organization_id || null,
+ data.metadata ? JSON.stringify(data.metadata) : null,
+ ]
+ );
+
+ return result.rows[0];
+ }
+
+ /**
+ * Mark email as sent with Resend email ID
+ */
+ async markEmailSent(trackingId: string, resendEmailId?: string): Promise {
+ await query(
+ `UPDATE email_events
+ SET sent_at = NOW(), resend_email_id = $2, updated_at = NOW()
+ WHERE tracking_id = $1`,
+ [trackingId, resendEmailId || null]
+ );
+ }
+
+ /**
+ * Get email event by tracking ID
+ */
+ async getByTrackingId(trackingId: string): Promise {
+ const result = await query(
+ 'SELECT * FROM email_events WHERE tracking_id = $1',
+ [trackingId]
+ );
+ return result.rows[0] || null;
+ }
+
+ /**
+ * Get email event by Resend email ID (for webhook processing)
+ */
+ async getByResendId(resendEmailId: string): Promise {
+ const result = await query(
+ 'SELECT * FROM email_events WHERE resend_email_id = $1',
+ [resendEmailId]
+ );
+ return result.rows[0] || null;
+ }
+
+ /**
+ * Check if we've already sent a specific email type to a user
+ */
+ async hasEmailBeenSent(data: {
+ email_type: string;
+ workos_user_id: string;
+ }): Promise {
+ const result = await query<{ exists: boolean }>(
+ `SELECT EXISTS(
+ SELECT 1 FROM email_events
+ WHERE email_type = $1
+ AND workos_user_id = $2
+ AND sent_at IS NOT NULL
+ ) as exists`,
+ [data.email_type, data.workos_user_id]
+ );
+ return result.rows[0]?.exists || false;
+ }
+
+ /**
+ * Record a click event
+ */
+ async recordClick(data: {
+ tracking_id: string;
+ link_name?: string;
+ destination_url: string;
+ ip_address?: string;
+ user_agent?: string;
+ referrer?: string;
+ utm_source?: string;
+ utm_medium?: string;
+ utm_campaign?: string;
+ }): Promise<{ emailEvent: EmailEvent; click: EmailClick } | null> {
+ // Get the email event
+ const emailEvent = await this.getByTrackingId(data.tracking_id);
+ if (!emailEvent) {
+ return null;
+ }
+
+ // Record the click
+ const clickResult = await query(
+ `INSERT INTO email_clicks (
+ email_event_id, link_name, destination_url,
+ ip_address, user_agent, referrer,
+ utm_source, utm_medium, utm_campaign
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING *`,
+ [
+ emailEvent.id,
+ data.link_name || null,
+ data.destination_url,
+ data.ip_address || null,
+ data.user_agent || null,
+ data.referrer || null,
+ data.utm_source || null,
+ data.utm_medium || null,
+ data.utm_campaign || null,
+ ]
+ );
+
+ // Update aggregate click count on email event
+ await query(
+ `UPDATE email_events
+ SET click_count = click_count + 1,
+ first_clicked_at = COALESCE(first_clicked_at, NOW()),
+ updated_at = NOW()
+ WHERE id = $1`,
+ [emailEvent.id]
+ );
+
+ // Refresh the email event to get updated counts
+ const updatedEvent = await this.getByTrackingId(data.tracking_id);
+
+ return {
+ emailEvent: updatedEvent!,
+ click: clickResult.rows[0],
+ };
+ }
+
+ /**
+ * Record email open (from Resend webhook)
+ */
+ async recordOpen(resendEmailId: string): Promise {
+ await query(
+ `UPDATE email_events
+ SET open_count = open_count + 1,
+ opened_at = COALESCE(opened_at, NOW()),
+ updated_at = NOW()
+ WHERE resend_email_id = $1`,
+ [resendEmailId]
+ );
+ }
+
+ /**
+ * Record email delivery (from Resend webhook)
+ */
+ async recordDelivery(resendEmailId: string): Promise {
+ await query(
+ `UPDATE email_events
+ SET delivered_at = NOW(), updated_at = NOW()
+ WHERE resend_email_id = $1`,
+ [resendEmailId]
+ );
+ }
+
+ /**
+ * Record email bounce (from Resend webhook)
+ */
+ async recordBounce(resendEmailId: string, reason?: string): Promise {
+ await query(
+ `UPDATE email_events
+ SET bounced_at = NOW(), bounce_reason = $2, updated_at = NOW()
+ WHERE resend_email_id = $1`,
+ [resendEmailId, reason || null]
+ );
+ }
+
+ /**
+ * Get email stats for a user
+ */
+ async getUserEmailStats(workosUserId: string): Promise<{
+ total_sent: number;
+ total_opened: number;
+ total_clicked: number;
+ }> {
+ const result = await query<{
+ total_sent: string;
+ total_opened: string;
+ total_clicked: string;
+ }>(
+ `SELECT
+ COUNT(*) FILTER (WHERE sent_at IS NOT NULL) as total_sent,
+ COUNT(*) FILTER (WHERE opened_at IS NOT NULL) as total_opened,
+ COUNT(*) FILTER (WHERE first_clicked_at IS NOT NULL) as total_clicked
+ FROM email_events
+ WHERE workos_user_id = $1`,
+ [workosUserId]
+ );
+
+ return {
+ total_sent: parseInt(result.rows[0]?.total_sent || '0', 10),
+ total_opened: parseInt(result.rows[0]?.total_opened || '0', 10),
+ total_clicked: parseInt(result.rows[0]?.total_clicked || '0', 10),
+ };
+ }
+
+ /**
+ * Get recent emails for an organization (for admin view)
+ */
+ async getOrgEmails(
+ workosOrganizationId: string,
+ limit = 50
+ ): Promise {
+ const result = await query(
+ `SELECT * FROM email_events
+ WHERE workos_organization_id = $1
+ ORDER BY created_at DESC
+ LIMIT $2`,
+ [workosOrganizationId, limit]
+ );
+ return result.rows;
+ }
+}
+
+export const emailDb = new EmailDatabase();
diff --git a/server/src/db/email-preferences-db.ts b/server/src/db/email-preferences-db.ts
new file mode 100644
index 000000000..bec173d92
--- /dev/null
+++ b/server/src/db/email-preferences-db.ts
@@ -0,0 +1,612 @@
+import { query } from './client.js';
+import crypto from 'crypto';
+
+export interface EmailCategory {
+ id: string;
+ name: string;
+ description: string | null;
+ default_enabled: boolean;
+ sort_order: number;
+ created_at: Date;
+}
+
+export interface UserEmailPreferences {
+ id: string;
+ workos_user_id: string;
+ email: string;
+ unsubscribe_token: string;
+ global_unsubscribe: boolean;
+ global_unsubscribe_at: Date | null;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface UserCategoryPreference {
+ id: string;
+ user_preference_id: string;
+ category_id: string;
+ enabled: boolean;
+ updated_at: Date;
+}
+
+export interface EmailTemplate {
+ id: string;
+ name: string;
+ description: string | null;
+ subject_template: string;
+ html_template: string;
+ text_template: string;
+ category_id: string | null;
+ available_variables: Record | null;
+ version: number;
+ last_edited_by: string | null;
+ last_edited_at: Date | null;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface EmailCampaign {
+ id: string;
+ name: string;
+ description: string | null;
+ subject: string;
+ html_content: string;
+ text_content: string;
+ category_id: string;
+ target_audience: string;
+ status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled';
+ scheduled_for: Date | null;
+ started_at: Date | null;
+ completed_at: Date | null;
+ total_recipients: number;
+ sent_count: number;
+ failed_count: number;
+ open_count: number;
+ click_count: number;
+ unsubscribe_count: number;
+ created_by: string | null;
+ created_at: Date;
+ updated_at: Date;
+}
+
+/**
+ * Generate a secure unsubscribe token
+ */
+function generateUnsubscribeToken(): string {
+ return crypto.randomBytes(32).toString('hex');
+}
+
+/**
+ * Database operations for email preferences
+ */
+export class EmailPreferencesDatabase {
+ // ==================== Categories ====================
+
+ /**
+ * Get all email categories
+ */
+ async getCategories(): Promise {
+ const result = await query(
+ 'SELECT * FROM email_categories ORDER BY sort_order ASC'
+ );
+ return result.rows;
+ }
+
+ /**
+ * Get a single category by ID
+ */
+ async getCategoryById(categoryId: string): Promise {
+ const result = await query(
+ 'SELECT * FROM email_categories WHERE id = $1',
+ [categoryId]
+ );
+ return result.rows[0] || null;
+ }
+
+ // ==================== User Preferences ====================
+
+ /**
+ * Get or create user email preferences
+ * Creates a new record with defaults if none exists
+ */
+ async getOrCreateUserPreferences(data: {
+ workos_user_id: string;
+ email: string;
+ }): Promise {
+ // Try to get existing
+ const existing = await query(
+ 'SELECT * FROM user_email_preferences WHERE workos_user_id = $1',
+ [data.workos_user_id]
+ );
+
+ if (existing.rows[0]) {
+ // Update email if changed
+ if (existing.rows[0].email !== data.email) {
+ await query(
+ 'UPDATE user_email_preferences SET email = $2, updated_at = NOW() WHERE workos_user_id = $1',
+ [data.workos_user_id, data.email]
+ );
+ existing.rows[0].email = data.email;
+ }
+ return existing.rows[0];
+ }
+
+ // Create new with default preferences
+ const token = generateUnsubscribeToken();
+ const result = await query(
+ `INSERT INTO user_email_preferences (workos_user_id, email, unsubscribe_token)
+ VALUES ($1, $2, $3)
+ RETURNING *`,
+ [data.workos_user_id, data.email, token]
+ );
+
+ return result.rows[0];
+ }
+
+ /**
+ * Get user preferences by unsubscribe token (no auth required)
+ */
+ async getUserPreferencesByToken(token: string): Promise {
+ const result = await query(
+ 'SELECT * FROM user_email_preferences WHERE unsubscribe_token = $1',
+ [token]
+ );
+ return result.rows[0] || null;
+ }
+
+ /**
+ * Get user preferences by user ID
+ */
+ async getUserPreferencesByUserId(workosUserId: string): Promise {
+ const result = await query(
+ 'SELECT * FROM user_email_preferences WHERE workos_user_id = $1',
+ [workosUserId]
+ );
+ return result.rows[0] || null;
+ }
+
+ /**
+ * Global unsubscribe (one-click unsubscribe from all non-transactional emails)
+ */
+ async globalUnsubscribe(token: string): Promise {
+ const result = await query(
+ `UPDATE user_email_preferences
+ SET global_unsubscribe = true, global_unsubscribe_at = NOW(), updated_at = NOW()
+ WHERE unsubscribe_token = $1
+ RETURNING id`,
+ [token]
+ );
+ return (result.rowCount ?? 0) > 0;
+ }
+
+ /**
+ * Re-subscribe (undo global unsubscribe)
+ */
+ async resubscribe(workosUserId: string): Promise {
+ const result = await query(
+ `UPDATE user_email_preferences
+ SET global_unsubscribe = false, global_unsubscribe_at = NULL, updated_at = NOW()
+ WHERE workos_user_id = $1
+ RETURNING id`,
+ [workosUserId]
+ );
+ return (result.rowCount ?? 0) > 0;
+ }
+
+ // ==================== Category Preferences ====================
+
+ /**
+ * Get user's category preferences with defaults applied
+ */
+ async getUserCategoryPreferences(workosUserId: string): Promise<
+ Array<{
+ category_id: string;
+ category_name: string;
+ category_description: string | null;
+ enabled: boolean;
+ is_override: boolean;
+ }>
+ > {
+ // Get user preference record
+ const userPrefs = await this.getUserPreferencesByUserId(workosUserId);
+
+ // Get all categories with any overrides
+ const result = await query<{
+ category_id: string;
+ category_name: string;
+ category_description: string | null;
+ default_enabled: boolean;
+ override_enabled: boolean | null;
+ }>(
+ `SELECT
+ c.id as category_id,
+ c.name as category_name,
+ c.description as category_description,
+ c.default_enabled,
+ ucp.enabled as override_enabled
+ FROM email_categories c
+ LEFT JOIN user_email_category_preferences ucp
+ ON ucp.category_id = c.id
+ AND ucp.user_preference_id = $1
+ ORDER BY c.sort_order ASC`,
+ [userPrefs?.id || null]
+ );
+
+ return result.rows.map((row) => ({
+ category_id: row.category_id,
+ category_name: row.category_name,
+ category_description: row.category_description,
+ enabled: row.override_enabled !== null ? row.override_enabled : row.default_enabled,
+ is_override: row.override_enabled !== null,
+ }));
+ }
+
+ /**
+ * Set preference for a specific category
+ */
+ async setCategoryPreference(data: {
+ workos_user_id: string;
+ email: string;
+ category_id: string;
+ enabled: boolean;
+ }): Promise {
+ // Ensure user preferences exist
+ const userPrefs = await this.getOrCreateUserPreferences({
+ workos_user_id: data.workos_user_id,
+ email: data.email,
+ });
+
+ // Upsert category preference
+ await query(
+ `INSERT INTO user_email_category_preferences (user_preference_id, category_id, enabled)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (user_preference_id, category_id)
+ DO UPDATE SET enabled = $3, updated_at = NOW()`,
+ [userPrefs.id, data.category_id, data.enabled]
+ );
+ }
+
+ /**
+ * Check if a user wants to receive a specific category of email
+ */
+ async shouldSendEmail(data: {
+ workos_user_id: string;
+ category_id: string;
+ }): Promise {
+ const userPrefs = await this.getUserPreferencesByUserId(data.workos_user_id);
+
+ // If no preferences exist, use category default
+ if (!userPrefs) {
+ const category = await this.getCategoryById(data.category_id);
+ return category?.default_enabled ?? true;
+ }
+
+ // Check global unsubscribe first
+ if (userPrefs.global_unsubscribe) {
+ return false;
+ }
+
+ // Check category-specific preference
+ const result = await query<{ enabled: boolean; default_enabled: boolean }>(
+ `SELECT
+ ucp.enabled,
+ c.default_enabled
+ FROM email_categories c
+ LEFT JOIN user_email_category_preferences ucp
+ ON ucp.category_id = c.id
+ AND ucp.user_preference_id = $1
+ WHERE c.id = $2`,
+ [userPrefs.id, data.category_id]
+ );
+
+ if (result.rows[0]) {
+ const row = result.rows[0];
+ return row.enabled !== null ? row.enabled : row.default_enabled;
+ }
+
+ return true; // Default to sending if category doesn't exist
+ }
+
+ // ==================== Templates ====================
+
+ /**
+ * Get all email templates
+ */
+ async getTemplates(): Promise {
+ const result = await query(
+ 'SELECT * FROM email_templates ORDER BY name ASC'
+ );
+ return result.rows;
+ }
+
+ /**
+ * Get a template by ID
+ */
+ async getTemplateById(templateId: string): Promise {
+ const result = await query(
+ 'SELECT * FROM email_templates WHERE id = $1',
+ [templateId]
+ );
+ return result.rows[0] || null;
+ }
+
+ /**
+ * Update a template
+ */
+ async updateTemplate(
+ templateId: string,
+ data: {
+ subject_template?: string;
+ html_template?: string;
+ text_template?: string;
+ last_edited_by: string;
+ }
+ ): Promise {
+ const result = await query(
+ `UPDATE email_templates SET
+ subject_template = COALESCE($2, subject_template),
+ html_template = COALESCE($3, html_template),
+ text_template = COALESCE($4, text_template),
+ last_edited_by = $5,
+ last_edited_at = NOW(),
+ version = version + 1,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *`,
+ [
+ templateId,
+ data.subject_template || null,
+ data.html_template || null,
+ data.text_template || null,
+ data.last_edited_by,
+ ]
+ );
+ return result.rows[0] || null;
+ }
+
+ // ==================== Campaigns ====================
+
+ /**
+ * Create a new email campaign
+ */
+ async createCampaign(data: {
+ name: string;
+ description?: string;
+ subject: string;
+ html_content: string;
+ text_content: string;
+ category_id: string;
+ target_audience?: string;
+ created_by?: string;
+ }): Promise {
+ const result = await query(
+ `INSERT INTO email_campaigns (
+ name, description, subject, html_content, text_content,
+ category_id, target_audience, created_by
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING *`,
+ [
+ data.name,
+ data.description || null,
+ data.subject,
+ data.html_content,
+ data.text_content,
+ data.category_id,
+ data.target_audience || 'all_subscribers',
+ data.created_by || null,
+ ]
+ );
+ return result.rows[0];
+ }
+
+ /**
+ * Get all campaigns
+ */
+ async getCampaigns(filters?: {
+ status?: string;
+ category_id?: string;
+ }): Promise {
+ let sql = 'SELECT * FROM email_campaigns WHERE 1=1';
+ const params: unknown[] = [];
+
+ if (filters?.status) {
+ params.push(filters.status);
+ sql += ` AND status = $${params.length}`;
+ }
+
+ if (filters?.category_id) {
+ params.push(filters.category_id);
+ sql += ` AND category_id = $${params.length}`;
+ }
+
+ sql += ' ORDER BY created_at DESC';
+
+ const result = await query(sql, params);
+ return result.rows;
+ }
+
+ /**
+ * Get a campaign by ID
+ */
+ async getCampaignById(campaignId: string): Promise {
+ const result = await query(
+ 'SELECT * FROM email_campaigns WHERE id = $1',
+ [campaignId]
+ );
+ return result.rows[0] || null;
+ }
+
+ /**
+ * Update campaign
+ */
+ async updateCampaign(
+ campaignId: string,
+ data: Partial<{
+ name: string;
+ description: string;
+ subject: string;
+ html_content: string;
+ text_content: string;
+ category_id: string;
+ target_audience: string;
+ status: string;
+ scheduled_for: Date;
+ }>
+ ): Promise {
+ // Only allow updates to draft campaigns
+ const campaign = await this.getCampaignById(campaignId);
+ if (!campaign || campaign.status !== 'draft') {
+ return null;
+ }
+
+ const setClauses: string[] = [];
+ const params: unknown[] = [campaignId];
+
+ if (data.name !== undefined) {
+ params.push(data.name);
+ setClauses.push(`name = $${params.length}`);
+ }
+ if (data.description !== undefined) {
+ params.push(data.description);
+ setClauses.push(`description = $${params.length}`);
+ }
+ if (data.subject !== undefined) {
+ params.push(data.subject);
+ setClauses.push(`subject = $${params.length}`);
+ }
+ if (data.html_content !== undefined) {
+ params.push(data.html_content);
+ setClauses.push(`html_content = $${params.length}`);
+ }
+ if (data.text_content !== undefined) {
+ params.push(data.text_content);
+ setClauses.push(`text_content = $${params.length}`);
+ }
+ if (data.category_id !== undefined) {
+ params.push(data.category_id);
+ setClauses.push(`category_id = $${params.length}`);
+ }
+ if (data.target_audience !== undefined) {
+ params.push(data.target_audience);
+ setClauses.push(`target_audience = $${params.length}`);
+ }
+ if (data.status !== undefined) {
+ params.push(data.status);
+ setClauses.push(`status = $${params.length}`);
+ }
+ if (data.scheduled_for !== undefined) {
+ params.push(data.scheduled_for);
+ setClauses.push(`scheduled_for = $${params.length}`);
+ }
+
+ if (setClauses.length === 0) {
+ return campaign;
+ }
+
+ setClauses.push('updated_at = NOW()');
+
+ const result = await query(
+ `UPDATE email_campaigns SET ${setClauses.join(', ')} WHERE id = $1 RETURNING *`,
+ params
+ );
+ return result.rows[0] || null;
+ }
+
+ /**
+ * Update campaign stats
+ */
+ async updateCampaignStats(
+ campaignId: string,
+ stats: Partial<{
+ total_recipients: number;
+ sent_count: number;
+ failed_count: number;
+ open_count: number;
+ click_count: number;
+ unsubscribe_count: number;
+ }>
+ ): Promise {
+ const setClauses: string[] = [];
+ const params: unknown[] = [campaignId];
+
+ Object.entries(stats).forEach(([key, value]) => {
+ if (value !== undefined) {
+ params.push(value);
+ setClauses.push(`${key} = $${params.length}`);
+ }
+ });
+
+ if (setClauses.length > 0) {
+ setClauses.push('updated_at = NOW()');
+ await query(
+ `UPDATE email_campaigns SET ${setClauses.join(', ')} WHERE id = $1`,
+ params
+ );
+ }
+ }
+
+ /**
+ * Get campaign stats summary
+ */
+ async getCampaignStats(): Promise<{
+ total_campaigns: number;
+ total_sent: number;
+ total_opened: number;
+ total_clicked: number;
+ avg_open_rate: number;
+ avg_click_rate: number;
+ }> {
+ const result = await query<{
+ total_campaigns: string;
+ total_sent: string;
+ total_opened: string;
+ total_clicked: string;
+ }>(
+ `SELECT
+ COUNT(*) as total_campaigns,
+ SUM(sent_count) as total_sent,
+ SUM(open_count) as total_opened,
+ SUM(click_count) as total_clicked
+ FROM email_campaigns
+ WHERE status = 'sent'`
+ );
+
+ const row = result.rows[0];
+ const totalSent = parseInt(row?.total_sent || '0', 10);
+ const totalOpened = parseInt(row?.total_opened || '0', 10);
+ const totalClicked = parseInt(row?.total_clicked || '0', 10);
+
+ return {
+ total_campaigns: parseInt(row?.total_campaigns || '0', 10),
+ total_sent: totalSent,
+ total_opened: totalOpened,
+ total_clicked: totalClicked,
+ avg_open_rate: totalSent > 0 ? (totalOpened / totalSent) * 100 : 0,
+ avg_click_rate: totalSent > 0 ? (totalClicked / totalSent) * 100 : 0,
+ };
+ }
+
+ // ==================== Unsubscribe from Category via Token ====================
+
+ /**
+ * Unsubscribe from a specific category using token (no auth)
+ */
+ async unsubscribeFromCategory(token: string, categoryId: string): Promise {
+ const userPrefs = await this.getUserPreferencesByToken(token);
+ if (!userPrefs) {
+ return false;
+ }
+
+ await query(
+ `INSERT INTO user_email_category_preferences (user_preference_id, category_id, enabled)
+ VALUES ($1, $2, false)
+ ON CONFLICT (user_preference_id, category_id)
+ DO UPDATE SET enabled = false, updated_at = NOW()`,
+ [userPrefs.id, categoryId]
+ );
+
+ return true;
+ }
+}
+
+export const emailPrefsDb = new EmailPreferencesDatabase();
diff --git a/server/src/db/migrations/037_email_tracking.sql b/server/src/db/migrations/037_email_tracking.sql
new file mode 100644
index 000000000..757a86e7b
--- /dev/null
+++ b/server/src/db/migrations/037_email_tracking.sql
@@ -0,0 +1,90 @@
+-- Migration: 037_email_tracking.sql
+-- Track email sends, opens, and clicks for engagement analytics
+
+-- Email events table - tracks all email lifecycle events
+CREATE TABLE IF NOT EXISTS email_events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Tracking identifier (used in click URLs)
+ tracking_id VARCHAR(32) NOT NULL UNIQUE,
+
+ -- Email details
+ email_type VARCHAR(50) NOT NULL, -- welcome_member, signup_user, etc.
+ recipient_email VARCHAR(255) NOT NULL,
+ subject VARCHAR(500),
+
+ -- User/org context (nullable - some emails may not have org context)
+ workos_user_id VARCHAR(255),
+ workos_organization_id VARCHAR(255),
+
+ -- Send status
+ sent_at TIMESTAMP WITH TIME ZONE,
+ resend_email_id VARCHAR(255), -- Resend's email ID for webhook correlation
+
+ -- Engagement tracking
+ opened_at TIMESTAMP WITH TIME ZONE,
+ open_count INTEGER DEFAULT 0,
+
+ -- Click tracking (aggregated - details in email_clicks)
+ first_clicked_at TIMESTAMP WITH TIME ZONE,
+ click_count INTEGER DEFAULT 0,
+
+ -- Delivery status
+ delivered_at TIMESTAMP WITH TIME ZONE,
+ bounced_at TIMESTAMP WITH TIME ZONE,
+ bounce_reason TEXT,
+
+ -- Metadata
+ metadata JSONB, -- Store any additional context (e.g., which email variant)
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Individual click events for detailed analytics
+CREATE TABLE IF NOT EXISTS email_clicks (
+ id SERIAL PRIMARY KEY,
+
+ -- Link to parent email
+ email_event_id UUID NOT NULL REFERENCES email_events(id) ON DELETE CASCADE,
+
+ -- Click details
+ link_name VARCHAR(100), -- e.g., 'cta_dashboard', 'cta_billing', 'footer_link'
+ destination_url TEXT NOT NULL,
+
+ -- When and from where
+ clicked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ ip_address VARCHAR(50),
+ user_agent TEXT,
+ referrer TEXT,
+
+ -- UTM parameters captured
+ utm_source VARCHAR(100),
+ utm_medium VARCHAR(100),
+ utm_campaign VARCHAR(100)
+);
+
+-- Indexes for efficient querying
+CREATE INDEX IF NOT EXISTS idx_email_events_tracking_id ON email_events(tracking_id);
+CREATE INDEX IF NOT EXISTS idx_email_events_user ON email_events(workos_user_id);
+CREATE INDEX IF NOT EXISTS idx_email_events_org ON email_events(workos_organization_id);
+CREATE INDEX IF NOT EXISTS idx_email_events_type ON email_events(email_type);
+CREATE INDEX IF NOT EXISTS idx_email_events_sent ON email_events(sent_at DESC);
+CREATE INDEX IF NOT EXISTS idx_email_events_resend_id ON email_events(resend_email_id);
+
+CREATE INDEX IF NOT EXISTS idx_email_clicks_event ON email_clicks(email_event_id);
+CREATE INDEX IF NOT EXISTS idx_email_clicks_time ON email_clicks(clicked_at DESC);
+
+-- Trigger for updated_at
+DROP TRIGGER IF EXISTS update_email_events_updated_at ON email_events;
+CREATE TRIGGER update_email_events_updated_at
+ BEFORE UPDATE ON email_events
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Comments
+COMMENT ON TABLE email_events IS 'Tracks all transactional email sends and engagement';
+COMMENT ON TABLE email_clicks IS 'Individual click events for email link tracking';
+COMMENT ON COLUMN email_events.tracking_id IS 'Short ID used in tracked URLs (e.g., /r/abc123)';
+COMMENT ON COLUMN email_events.email_type IS 'Type: welcome_member, signup_user, etc.';
+COMMENT ON COLUMN email_clicks.link_name IS 'Semantic name for the link (cta_dashboard, cta_billing, etc.)';
diff --git a/server/src/db/migrations/038_email_preferences.sql b/server/src/db/migrations/038_email_preferences.sql
new file mode 100644
index 000000000..22938a7b1
--- /dev/null
+++ b/server/src/db/migrations/038_email_preferences.sql
@@ -0,0 +1,187 @@
+-- Migration: 038_email_preferences.sql
+-- User email preferences and unsubscribe management
+
+-- Email categories that users can subscribe/unsubscribe from
+-- Transactional emails (welcome, security) are always sent and not in this list
+CREATE TABLE IF NOT EXISTS email_categories (
+ id VARCHAR(50) PRIMARY KEY, -- e.g., 'newsletter', 'working_groups', 'releases'
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+ default_enabled BOOLEAN DEFAULT true, -- Default preference for new users
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- User email preferences - which categories each user wants
+CREATE TABLE IF NOT EXISTS user_email_preferences (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ workos_user_id VARCHAR(255) NOT NULL,
+ email VARCHAR(255) NOT NULL, -- Denormalized for unsubscribe without auth
+
+ -- Unsubscribe token for one-click unsubscribe (no auth required)
+ unsubscribe_token VARCHAR(64) NOT NULL UNIQUE,
+
+ -- Global opt-out (unsubscribes from ALL non-transactional emails)
+ global_unsubscribe BOOLEAN DEFAULT false,
+ global_unsubscribe_at TIMESTAMP WITH TIME ZONE,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ UNIQUE(workos_user_id)
+);
+
+-- Per-category preferences (only stores overrides from default)
+CREATE TABLE IF NOT EXISTS user_email_category_preferences (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_preference_id UUID NOT NULL REFERENCES user_email_preferences(id) ON DELETE CASCADE,
+ category_id VARCHAR(50) NOT NULL REFERENCES email_categories(id) ON DELETE CASCADE,
+ enabled BOOLEAN NOT NULL,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ UNIQUE(user_preference_id, category_id)
+);
+
+-- Email templates that admins can edit
+CREATE TABLE IF NOT EXISTS email_templates (
+ id VARCHAR(50) PRIMARY KEY, -- e.g., 'welcome_member', 'newsletter'
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+
+ -- Template content (supports {{variable}} substitution)
+ subject_template VARCHAR(500) NOT NULL,
+ html_template TEXT NOT NULL,
+ text_template TEXT NOT NULL,
+
+ -- Which category this template belongs to (null = transactional)
+ category_id VARCHAR(50) REFERENCES email_categories(id),
+
+ -- Template variables documentation
+ available_variables JSONB, -- e.g., {"firstName": "User's first name", "orgName": "Organization name"}
+
+ -- Versioning
+ version INTEGER DEFAULT 1,
+ last_edited_by VARCHAR(255),
+ last_edited_at TIMESTAMP WITH TIME ZONE,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Email campaigns (one-off sends like newsletters)
+CREATE TABLE IF NOT EXISTS email_campaigns (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Campaign details
+ name VARCHAR(200) NOT NULL,
+ description TEXT,
+
+ -- Content
+ subject VARCHAR(500) NOT NULL,
+ html_content TEXT NOT NULL,
+ text_content TEXT NOT NULL,
+
+ -- Targeting
+ category_id VARCHAR(50) NOT NULL REFERENCES email_categories(id),
+ target_audience VARCHAR(50) DEFAULT 'all_subscribers', -- all_subscribers, members_only, non_members
+
+ -- Status
+ status VARCHAR(20) DEFAULT 'draft'
+ CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'cancelled')),
+
+ -- Scheduling
+ scheduled_for TIMESTAMP WITH TIME ZONE,
+ started_at TIMESTAMP WITH TIME ZONE,
+ completed_at TIMESTAMP WITH TIME ZONE,
+
+ -- Stats (updated as sends complete)
+ total_recipients INTEGER DEFAULT 0,
+ sent_count INTEGER DEFAULT 0,
+ failed_count INTEGER DEFAULT 0,
+ open_count INTEGER DEFAULT 0,
+ click_count INTEGER DEFAULT 0,
+ unsubscribe_count INTEGER DEFAULT 0,
+
+ -- Who created/edited
+ created_by VARCHAR(255),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_user_email_prefs_user ON user_email_preferences(workos_user_id);
+CREATE INDEX IF NOT EXISTS idx_user_email_prefs_email ON user_email_preferences(email);
+CREATE INDEX IF NOT EXISTS idx_user_email_prefs_token ON user_email_preferences(unsubscribe_token);
+CREATE INDEX IF NOT EXISTS idx_email_campaigns_status ON email_campaigns(status);
+CREATE INDEX IF NOT EXISTS idx_email_campaigns_category ON email_campaigns(category_id);
+
+-- Trigger for updated_at
+DROP TRIGGER IF EXISTS update_user_email_preferences_updated_at ON user_email_preferences;
+CREATE TRIGGER update_user_email_preferences_updated_at
+ BEFORE UPDATE ON user_email_preferences
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+DROP TRIGGER IF EXISTS update_email_templates_updated_at ON email_templates;
+CREATE TRIGGER update_email_templates_updated_at
+ BEFORE UPDATE ON email_templates
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+DROP TRIGGER IF EXISTS update_email_campaigns_updated_at ON email_campaigns;
+CREATE TRIGGER update_email_campaigns_updated_at
+ BEFORE UPDATE ON email_campaigns
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Seed default email categories
+INSERT INTO email_categories (id, name, description, default_enabled, sort_order) VALUES
+ ('newsletter', 'Newsletter', 'Monthly newsletter with industry updates and member news', true, 1),
+ ('working_groups', 'Working Group Updates', 'Summaries and updates from working groups you''re part of', true, 2),
+ ('releases', 'Release Announcements', 'New AdCP releases, features, and protocol updates', true, 3),
+ ('member_directory', 'Member Directory Updates', 'New members joining and member profile updates', true, 4),
+ ('events', 'Events & Webinars', 'Upcoming events, webinars, and community gatherings', true, 5)
+ON CONFLICT (id) DO NOTHING;
+
+-- Seed default email templates
+INSERT INTO email_templates (id, name, description, subject_template, html_template, text_template, category_id, available_variables) VALUES
+ (
+ 'welcome_member',
+ 'Welcome Email (New Member)',
+ 'Sent when an organization becomes a paying member',
+ 'Welcome to AgenticAdvertising.org!',
+ '',
+ '',
+ NULL, -- Transactional
+ '{"organizationName": "Name of the organization", "productName": "Subscription plan name"}'
+ ),
+ (
+ 'signup_user',
+ 'Welcome Email (New User)',
+ 'Sent when a new user signs up',
+ 'Welcome to AgenticAdvertising.org',
+ '',
+ '',
+ NULL, -- Transactional
+ '{"firstName": "User first name", "organizationName": "Organization name", "hasActiveSubscription": "Whether org is a member"}'
+ ),
+ (
+ 'newsletter',
+ 'Monthly Newsletter',
+ 'Monthly digest of news, updates, and member highlights',
+ '{{month}} Newsletter - AgenticAdvertising.org',
+ '',
+ '',
+ 'newsletter',
+ '{"month": "Newsletter month (e.g., January 2025)", "articles": "Array of article objects"}'
+ )
+ON CONFLICT (id) DO NOTHING;
+
+-- Comments
+COMMENT ON TABLE email_categories IS 'Categories of emails users can subscribe/unsubscribe from';
+COMMENT ON TABLE user_email_preferences IS 'User-level email preferences and unsubscribe tokens';
+COMMENT ON TABLE user_email_category_preferences IS 'Per-category preference overrides';
+COMMENT ON TABLE email_templates IS 'Admin-editable email templates';
+COMMENT ON TABLE email_campaigns IS 'One-off email campaigns like newsletters';
+COMMENT ON COLUMN user_email_preferences.unsubscribe_token IS 'Token for one-click unsubscribe without requiring login';
+COMMENT ON COLUMN user_email_preferences.global_unsubscribe IS 'If true, user receives no non-transactional emails';
diff --git a/server/src/http.ts b/server/src/http.ts
index 80213afb7..81ac871a6 100644
--- a/server/src/http.ts
+++ b/server/src/http.ts
@@ -45,6 +45,8 @@ import {
notifyWorkingGroupPost,
} from "./notifications/slack.js";
import { createAdminRouter } from "./routes/admin.js";
+import { sendWelcomeEmail, sendUserSignupEmail, emailDb } from "./notifications/email.js";
+import { emailPrefsDb } from "./db/email-preferences-db.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -318,6 +320,335 @@ export class HTTPServer {
}
res.redirect('/team.html' + (req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''));
});
+
+ // Email click tracker - records clicks and redirects to destination
+ this.app.get('/r/:trackingId', async (req, res) => {
+ const { trackingId } = req.params;
+ const destinationUrl = req.query.to as string;
+ const linkName = req.query.ln as string;
+
+ if (!destinationUrl) {
+ logger.warn({ trackingId }, 'Click tracker missing destination URL');
+ return res.redirect('/');
+ }
+
+ try {
+ // Record the click
+ await emailDb.recordClick({
+ tracking_id: trackingId,
+ link_name: linkName,
+ destination_url: destinationUrl,
+ ip_address: req.ip,
+ user_agent: req.get('user-agent'),
+ referrer: req.get('referer'),
+ utm_source: req.query.utm_source as string,
+ utm_medium: req.query.utm_medium as string,
+ utm_campaign: req.query.utm_campaign as string,
+ });
+
+ logger.debug({ trackingId, linkName, destination: destinationUrl }, 'Email click recorded');
+ } catch (error) {
+ // Log but don't fail - always redirect even if tracking fails
+ logger.error({ error, trackingId }, 'Failed to record email click');
+ }
+
+ // Always redirect to destination
+ res.redirect(destinationUrl);
+ });
+
+ // ==================== Email Preferences & Unsubscribe ====================
+
+ // One-click unsubscribe (no auth required) - POST for RFC 8058 compliance
+ this.app.post('/unsubscribe/:token', async (req, res) => {
+ const { token } = req.params;
+ const { category } = req.body;
+
+ try {
+ if (category) {
+ // Unsubscribe from specific category
+ const success = await emailPrefsDb.unsubscribeFromCategory(token, category);
+ if (success) {
+ logger.info({ token: token.substring(0, 8) + '...', category }, 'User unsubscribed from category');
+ return res.json({ success: true, message: `Unsubscribed from ${category}` });
+ }
+ } else {
+ // Global unsubscribe
+ const success = await emailPrefsDb.globalUnsubscribe(token);
+ if (success) {
+ logger.info({ token: token.substring(0, 8) + '...' }, 'User globally unsubscribed');
+ return res.json({ success: true, message: 'Unsubscribed from all emails' });
+ }
+ }
+
+ return res.status(404).json({ success: false, message: 'Invalid unsubscribe link' });
+ } catch (error) {
+ logger.error({ error, token: token.substring(0, 8) + '...' }, 'Error processing unsubscribe');
+ return res.status(500).json({ success: false, message: 'Error processing unsubscribe' });
+ }
+ });
+
+ // Unsubscribe page (GET - shows confirmation page, handles one-click via List-Unsubscribe-Post)
+ this.app.get('/unsubscribe/:token', async (req, res) => {
+ const { token } = req.params;
+
+ try {
+ const prefs = await emailPrefsDb.getUserPreferencesByToken(token);
+ if (!prefs) {
+ return res.status(404).send('Invalid unsubscribe link');
+ }
+
+ // Get categories for the preferences page
+ const categories = await emailPrefsDb.getCategories();
+ const userCategoryPrefs = prefs.workos_user_id
+ ? await emailPrefsDb.getUserCategoryPreferences(prefs.workos_user_id)
+ : categories.map(c => ({
+ category_id: c.id,
+ category_name: c.name,
+ category_description: c.description,
+ enabled: c.default_enabled,
+ is_override: false,
+ }));
+
+ // Serve a simple preferences management page
+ res.send(`
+
+
+
+
+
+ Email Preferences - AgenticAdvertising.org
+
+
+
+
+ Email Preferences
+ Manage which emails you receive from AgenticAdvertising.org
+
+ Your preferences have been saved.
+
+ ${prefs.global_unsubscribe ? `
+
+
You are currently unsubscribed from all emails.
+
You will only receive essential transactional emails (like security alerts).
+
+
+ ` : `
+
+ ${userCategoryPrefs.map(cat => `
+
+
+
${cat.category_name}
+
${cat.category_description || ''}
+
+
+
+ `).join('')}
+
+
+
+
Want to stop receiving all non-essential emails?
+
+
+ `}
+
+
+
+
+ `);
+ } catch (error) {
+ logger.error({ error }, 'Error rendering unsubscribe page');
+ res.status(500).send('Error loading preferences');
+ }
+ });
+
+ // Update category preference via token (no auth required)
+ this.app.post('/api/email-preferences/category', async (req, res) => {
+ const { token, category_id, enabled } = req.body;
+
+ if (!token || !category_id || enabled === undefined) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+
+ try {
+ const prefs = await emailPrefsDb.getUserPreferencesByToken(token);
+ if (!prefs) {
+ return res.status(404).json({ error: 'Invalid token' });
+ }
+
+ await emailPrefsDb.setCategoryPreference({
+ workos_user_id: prefs.workos_user_id,
+ email: prefs.email,
+ category_id,
+ enabled,
+ });
+
+ logger.info({ userId: prefs.workos_user_id, category_id, enabled }, 'Category preference updated');
+ res.json({ success: true });
+ } catch (error) {
+ logger.error({ error }, 'Error updating category preference');
+ res.status(500).json({ error: 'Error updating preference' });
+ }
+ });
+
+ // Resubscribe via token (no auth required)
+ this.app.post('/api/email-preferences/resubscribe', async (req, res) => {
+ const { token } = req.body;
+
+ if (!token) {
+ return res.status(400).json({ error: 'Missing token' });
+ }
+
+ try {
+ const prefs = await emailPrefsDb.getUserPreferencesByToken(token);
+ if (!prefs) {
+ return res.status(404).json({ error: 'Invalid token' });
+ }
+
+ await emailPrefsDb.resubscribe(prefs.workos_user_id);
+ logger.info({ userId: prefs.workos_user_id }, 'User resubscribed');
+ res.json({ success: true });
+ } catch (error) {
+ logger.error({ error }, 'Error processing resubscribe');
+ res.status(500).json({ error: 'Error processing resubscribe' });
+ }
+ });
+
+ // Get email categories (public)
+ this.app.get('/api/email-preferences/categories', async (req, res) => {
+ try {
+ const categories = await emailPrefsDb.getCategories();
+ res.json({ categories });
+ } catch (error) {
+ logger.error({ error }, 'Error fetching email categories');
+ res.status(500).json({ error: 'Error fetching categories' });
+ }
+ });
+
+ // Get user's email preferences (authenticated)
+ this.app.get('/api/email-preferences', requireAuth, async (req, res) => {
+ try {
+ const userId = (req as any).user.id;
+ const userEmail = (req as any).user.email;
+
+ // Get or create preferences
+ const prefs = await emailPrefsDb.getOrCreateUserPreferences({
+ workos_user_id: userId,
+ email: userEmail,
+ });
+
+ // Get category preferences
+ const categoryPrefs = await emailPrefsDb.getUserCategoryPreferences(userId);
+
+ res.json({
+ global_unsubscribe: prefs.global_unsubscribe,
+ categories: categoryPrefs,
+ });
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user preferences');
+ res.status(500).json({ error: 'Error fetching preferences' });
+ }
+ });
+
+ // Update user's email preferences (authenticated)
+ this.app.post('/api/email-preferences', requireAuth, async (req, res) => {
+ try {
+ const userId = (req as any).user.id;
+ const userEmail = (req as any).user.email;
+ const { category_id, enabled } = req.body;
+
+ if (!category_id || enabled === undefined) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+
+ await emailPrefsDb.setCategoryPreference({
+ workos_user_id: userId,
+ email: userEmail,
+ category_id,
+ enabled,
+ });
+
+ res.json({ success: true });
+ } catch (error) {
+ logger.error({ error }, 'Error updating preferences');
+ res.status(500).json({ error: 'Error updating preferences' });
+ }
+ });
+
+ // Resubscribe for authenticated users
+ this.app.post('/api/email-preferences/resubscribe-me', requireAuth, async (req, res) => {
+ try {
+ const userId = (req as any).user.id;
+
+ await emailPrefsDb.resubscribe(userId);
+ logger.info({ userId }, 'User resubscribed via dashboard');
+ res.json({ success: true });
+ } catch (error) {
+ logger.error({ error }, 'Error processing resubscribe');
+ res.status(500).json({ error: 'Error processing resubscribe' });
+ }
+ });
+
this.app.get('/dashboard', async (req, res) => {
// Redirect to AAO for auth-requiring pages when on AdCP domain
if (this.isAdcpDomain(req)) {
@@ -348,6 +679,40 @@ export class HTTPServer {
}
});
+ // Dashboard sub-pages with sidebar navigation
+ // Helper to serve dashboard pages with template variable replacement
+ const serveDashboardPage = async (req: express.Request, res: express.Response, filename: string) => {
+ if (this.isAdcpDomain(req)) {
+ return res.redirect(`https://agenticadvertising.org/dashboard/${filename.replace('dashboard-', '').replace('.html', '')}`);
+ }
+ try {
+ const fs = await import('fs/promises');
+ const pagePath = process.env.NODE_ENV === 'production'
+ ? path.join(__dirname, `../server/public/${filename}`)
+ : path.join(__dirname, `../public/${filename}`);
+ let html = await fs.readFile(pagePath, 'utf-8');
+
+ // Replace template variables (for billing page with Stripe)
+ html = html
+ .replace(/\{\{STRIPE_PUBLISHABLE_KEY\}\}/g, process.env.STRIPE_PUBLISHABLE_KEY || '')
+ .replace(/\{\{STRIPE_PRICING_TABLE_ID\}\}/g, process.env.STRIPE_PRICING_TABLE_ID || '')
+ .replace(/\{\{STRIPE_PRICING_TABLE_ID_INDIVIDUAL\}\}/g, process.env.STRIPE_PRICING_TABLE_ID_INDIVIDUAL || process.env.STRIPE_PRICING_TABLE_ID || '');
+
+ res.setHeader('Content-Type', 'text/html');
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+ res.setHeader('Pragma', 'no-cache');
+ res.setHeader('Expires', '0');
+ res.send(html);
+ } catch (error) {
+ logger.error({ err: error, filename }, 'Error serving dashboard page');
+ res.status(500).send('Error loading page');
+ }
+ };
+
+ this.app.get('/dashboard/settings', (req, res) => serveDashboardPage(req, res, 'dashboard-settings.html'));
+ this.app.get('/dashboard/billing', (req, res) => serveDashboardPage(req, res, 'dashboard-billing.html'));
+ this.app.get('/dashboard/emails', (req, res) => serveDashboardPage(req, res, 'dashboard-emails.html'));
+
// API endpoints
// Public config endpoint - returns feature flags and auth state for nav
@@ -1104,6 +1469,15 @@ export class HTTPServer {
interval,
}).catch(err => logger.error({ err }, 'Failed to send Slack notification'));
+// Send welcome email to new member
+ sendWelcomeEmail({
+ to: userEmail,
+ organizationName: org.name || 'Unknown Organization',
+ productName,
+ workosUserId: workosUser.id,
+ workosOrganizationId: org.workos_organization_id,
+ }).catch(err => logger.error({ err }, 'Failed to send welcome email'));
+
// Record to org_activities for prospect tracking
const amountStr = amount ? `$${(amount / 100).toFixed(2)}` : '';
const intervalStr = interval ? `/${interval}` : '';
@@ -3827,6 +4201,13 @@ Disallow: /api/admin/
res.sendFile(usersPath);
});
+ this.app.get('/admin/email', requireAuth, requireAdmin, (req, res) => {
+ const emailPath = process.env.NODE_ENV === 'production'
+ ? path.join(__dirname, '../server/public/admin-email.html')
+ : path.join(__dirname, '../public/admin-email.html');
+ res.sendFile(emailPath);
+ });
+
// Registry API endpoints (consolidated agents, publishers, lookups)
this.setupRegistryRoutes();
}
@@ -4347,7 +4728,12 @@ Disallow: /api/admin/
// This happens when:
// 1. User has never accepted them, OR
// 2. The version has been updated since they last accepted
+ let isFirstTimeUser = false;
try {
+ // Check if user has ANY prior acceptances (to detect first-time users)
+ const priorAcceptances = await orgDb.getUserAgreementAcceptances(user.id);
+ isFirstTimeUser = priorAcceptances.length === 0;
+
const tosAgreement = await orgDb.getCurrentAgreementByType('terms_of_service');
const privacyAgreement = await orgDb.getCurrentAgreementByType('privacy_policy');
@@ -4409,6 +4795,35 @@ Disallow: /api/admin/
logger.debug({ count: memberships.data.length }, 'Organization memberships retrieved');
+ // Send welcome email to first-time users (async, don't block auth flow)
+ if (isFirstTimeUser && memberships.data.length > 0) {
+ // Get org details to determine subscription status
+ const firstMembership = memberships.data[0];
+ const orgId = firstMembership.organizationId;
+
+ // Fire and forget - don't block the auth callback
+ (async () => {
+ try {
+ const org = await orgDb.getOrganization(orgId);
+ const workosOrg = await workos!.organizations.getOrganization(orgId);
+ const hasActiveSubscription = org?.subscription_status === 'active';
+
+ await sendUserSignupEmail({
+ to: user.email,
+ firstName: user.firstName || undefined,
+ organizationName: workosOrg?.name || org?.name || undefined,
+ hasActiveSubscription,
+ workosUserId: user.id,
+ workosOrganizationId: orgId,
+ });
+
+ logger.info({ userId: user.id, orgId, hasActiveSubscription }, 'First-time user signup email sent');
+ } catch (emailError) {
+ logger.error({ error: emailError, userId: user.id }, 'Failed to send signup email');
+ }
+ })();
+ }
+
// Parse return_to and slack_user_id from state
let returnTo = '/dashboard';
let slackUserIdToLink: string | undefined;
@@ -9927,6 +10342,100 @@ Disallow: /api/admin/
}
});
+ // ============== Admin Email Endpoints ==============
+
+ // GET /api/admin/email/stats - Email statistics for admin dashboard
+ this.app.get('/api/admin/email/stats', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const pool = getPool();
+
+ // Get total emails sent
+ const sentResult = await pool.query(
+ `SELECT COUNT(*) as count FROM email_events WHERE sent_at IS NOT NULL`
+ );
+ const totalSent = parseInt(sentResult.rows[0]?.count || '0');
+
+ // Get open rate
+ const openResult = await pool.query(
+ `SELECT
+ COUNT(*) FILTER (WHERE opened_at IS NOT NULL) as opened,
+ COUNT(*) as total
+ FROM email_events
+ WHERE sent_at IS NOT NULL`
+ );
+ const avgOpenRate = openResult.rows[0]?.total > 0
+ ? (parseInt(openResult.rows[0].opened) / parseInt(openResult.rows[0].total)) * 100
+ : 0;
+
+ // Get click rate
+ const clickResult = await pool.query(
+ `SELECT
+ COUNT(*) FILTER (WHERE first_clicked_at IS NOT NULL) as clicked,
+ COUNT(*) as total
+ FROM email_events
+ WHERE sent_at IS NOT NULL`
+ );
+ const avgClickRate = clickResult.rows[0]?.total > 0
+ ? (parseInt(clickResult.rows[0].clicked) / parseInt(clickResult.rows[0].total)) * 100
+ : 0;
+
+ // Get campaign count
+ const campaignResult = await pool.query(
+ `SELECT COUNT(*) as count FROM email_campaigns`
+ );
+ const totalCampaigns = parseInt(campaignResult.rows[0]?.count || '0');
+
+ res.json({
+ total_sent: totalSent,
+ avg_open_rate: avgOpenRate,
+ avg_click_rate: avgClickRate,
+ total_campaigns: totalCampaigns,
+ });
+ } catch (error) {
+ logger.error({ error }, 'Error fetching email stats');
+ res.status(500).json({ error: 'Failed to fetch email stats' });
+ }
+ });
+
+ // GET /api/admin/email/campaigns - List all campaigns
+ this.app.get('/api/admin/email/campaigns', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const campaigns = await emailPrefsDb.getCampaigns();
+ res.json({ campaigns });
+ } catch (error) {
+ logger.error({ error }, 'Error fetching campaigns');
+ res.status(500).json({ error: 'Failed to fetch campaigns' });
+ }
+ });
+
+ // GET /api/admin/email/templates - List all templates
+ this.app.get('/api/admin/email/templates', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const templates = await emailPrefsDb.getTemplates();
+ res.json({ templates });
+ } catch (error) {
+ logger.error({ error }, 'Error fetching templates');
+ res.status(500).json({ error: 'Failed to fetch templates' });
+ }
+ });
+
+ // GET /api/admin/email/recent - Recent email sends
+ this.app.get('/api/admin/email/recent', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const pool = getPool();
+ const result = await pool.query(
+ `SELECT *
+ FROM email_events
+ ORDER BY created_at DESC
+ LIMIT 100`
+ );
+ res.json({ emails: result.rows });
+ } catch (error) {
+ logger.error({ error }, 'Error fetching recent emails');
+ res.status(500).json({ error: 'Failed to fetch recent emails' });
+ }
+ });
+
// ============== Slack Public Endpoints (Slash Commands, Events) ==============
// POST /api/slack/commands - Handle Slack slash commands
diff --git a/server/src/notifications/email.ts b/server/src/notifications/email.ts
new file mode 100644
index 000000000..3f870e288
--- /dev/null
+++ b/server/src/notifications/email.ts
@@ -0,0 +1,538 @@
+/**
+ * Email notification service for AgenticAdvertising.org member events
+ * With click tracking, event-based send recording, and preference management
+ */
+
+import { Resend } from 'resend';
+import { createLogger } from '../logger.js';
+import { emailDb } from '../db/email-db.js';
+import { emailPrefsDb } from '../db/email-preferences-db.js';
+
+const logger = createLogger('email');
+
+const RESEND_API_KEY = process.env.RESEND_API_KEY;
+
+const resend = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null;
+
+if (!RESEND_API_KEY) {
+ logger.warn('RESEND_API_KEY not set - email notifications will be disabled');
+}
+
+const FROM_EMAIL = 'AgenticAdvertising.org ';
+const BASE_URL = process.env.BASE_URL || 'https://agenticadvertising.org';
+
+/**
+ * Create a tracked URL that redirects through our click tracker
+ */
+function trackedUrl(trackingId: string, linkName: string, destinationUrl: string): string {
+ const params = new URLSearchParams({
+ to: destinationUrl,
+ ln: linkName,
+ });
+ return `${BASE_URL}/r/${trackingId}?${params.toString()}`;
+}
+
+/**
+ * Generate standard email footer HTML with optional unsubscribe links
+ * @param trackingId - The tracking ID for URL tracking
+ * @param unsubscribeToken - Token for one-click unsubscribe (null for transactional emails)
+ * @param category - Optional category name for specific unsubscribe text
+ */
+function generateFooterHtml(
+ trackingId: string,
+ unsubscribeToken: string | null,
+ category?: string
+): string {
+ const websiteUrl = trackedUrl(trackingId, 'footer_website', 'https://agenticadvertising.org');
+
+ let unsubscribeSection = '';
+ if (unsubscribeToken) {
+ const unsubscribeUrl = trackedUrl(trackingId, 'footer_unsubscribe', `${BASE_URL}/unsubscribe/${unsubscribeToken}`);
+ const preferencesUrl = trackedUrl(trackingId, 'footer_preferences', `${BASE_URL}/unsubscribe/${unsubscribeToken}`);
+
+ unsubscribeSection = `
+
+ Unsubscribe
+ ${category ? ` from ${category}` : ''} |
+ Manage email preferences
+
`;
+ }
+
+ return `
+
+
+
+ AgenticAdvertising.org
+ agenticadvertising.org
+
+ ${unsubscribeSection}`;
+}
+
+/**
+ * Generate standard email footer text with optional unsubscribe links
+ */
+function generateFooterText(unsubscribeToken: string | null, category?: string): string {
+ let footer = `---
+AgenticAdvertising.org
+https://agenticadvertising.org`;
+
+ if (unsubscribeToken) {
+ footer += `
+
+Unsubscribe${category ? ` from ${category}` : ''}: ${BASE_URL}/unsubscribe/${unsubscribeToken}
+Manage email preferences: ${BASE_URL}/unsubscribe/${unsubscribeToken}`;
+ }
+
+ return footer;
+}
+
+/**
+ * Get or create an unsubscribe token for a user
+ */
+async function getUnsubscribeToken(workosUserId: string, email: string): Promise {
+ const prefs = await emailPrefsDb.getOrCreateUserPreferences({
+ workos_user_id: workosUserId,
+ email,
+ });
+ return prefs.unsubscribe_token;
+}
+
+/**
+ * Email types for tracking
+ */
+export type EmailType = 'welcome_member' | 'signup_user' | 'signup_user_member' | 'signup_user_nonmember';
+
+/**
+ * Send welcome email to new members after subscription is created
+ * Now with tracking!
+ */
+export async function sendWelcomeEmail(data: {
+ to: string;
+ organizationName: string;
+ productName?: string;
+ workosUserId?: string;
+ workosOrganizationId?: string;
+}): Promise {
+ if (!resend) {
+ logger.debug('Resend not configured, skipping welcome email');
+ return false;
+ }
+
+ const emailType: EmailType = 'welcome_member';
+ const subject = 'Welcome to AgenticAdvertising.org!';
+
+ try {
+ // Create tracking record first
+ const emailEvent = await emailDb.createEmailEvent({
+ email_type: emailType,
+ recipient_email: data.to,
+ subject,
+ workos_user_id: data.workosUserId,
+ workos_organization_id: data.workosOrganizationId,
+ metadata: { organizationName: data.organizationName, productName: data.productName },
+ });
+
+ const trackingId = emailEvent.tracking_id;
+
+ // Build tracked URLs
+ const dashboardUrl = trackedUrl(trackingId, 'cta_dashboard', 'https://agenticadvertising.org/dashboard');
+ const websiteUrl = trackedUrl(trackingId, 'footer_website', 'https://agenticadvertising.org');
+
+ // Welcome email is transactional - no unsubscribe link
+ const footerHtml = generateFooterHtml(trackingId, null);
+ const footerText = generateFooterText(null);
+
+ const { data: sendData, error } = await resend.emails.send({
+ from: FROM_EMAIL,
+ to: data.to,
+ subject,
+ html: `
+
+
+
+
+
+
+
+
+
Welcome to AgenticAdvertising.org!
+
+
+ Hi there,
+
+ Thank you for becoming a member of AgenticAdvertising.org! We're excited to have ${data.organizationName} join us.
+
+ As a member, you now have access to:
+
+
+ - Member Directory - Connect with other members working on agentic advertising
+ - Working Groups - Participate in shaping the future of AdCP
+ - Member Profile - Showcase your organization's capabilities
+
+
+ To get started, visit your dashboard to set up your member profile:
+
+
+ Go to Dashboard
+
+
+ If you have any questions, just reply to this email - we're happy to help.
+
+
+ Best,
+ The AgenticAdvertising.org Team
+
+ ${footerHtml}
+
+
+ `.trim(),
+ text: `
+Welcome to AgenticAdvertising.org!
+
+Hi there,
+
+Thank you for becoming a member of AgenticAdvertising.org! We're excited to have ${data.organizationName} join us.
+
+As a member, you now have access to:
+- Member Directory - Connect with other members working on agentic advertising
+- Working Groups - Participate in shaping the future of AdCP
+- Member Profile - Showcase your organization's capabilities
+
+To get started, visit your dashboard to set up your member profile:
+https://agenticadvertising.org/dashboard
+
+If you have any questions, just reply to this email - we're happy to help.
+
+Best,
+The AgenticAdvertising.org Team
+
+${footerText}
+ `.trim(),
+ });
+
+ if (error) {
+ logger.error({ error, to: data.to, trackingId }, 'Failed to send welcome email');
+ return false;
+ }
+
+ // Mark as sent with Resend's email ID
+ await emailDb.markEmailSent(trackingId, sendData?.id);
+
+ logger.info({ to: data.to, organization: data.organizationName, trackingId }, 'Welcome email sent');
+ return true;
+ } catch (error) {
+ logger.error({ error, to: data.to }, 'Error sending welcome email');
+ return false;
+ }
+}
+
+/**
+ * Check if we've already sent a signup email to this user
+ */
+export async function hasSignupEmailBeenSent(workosUserId: string): Promise {
+ // Check for any variant of signup email
+ const memberSent = await emailDb.hasEmailBeenSent({
+ email_type: 'signup_user_member',
+ workos_user_id: workosUserId,
+ });
+ const nonMemberSent = await emailDb.hasEmailBeenSent({
+ email_type: 'signup_user_nonmember',
+ workos_user_id: workosUserId,
+ });
+ // Also check legacy type
+ const legacySent = await emailDb.hasEmailBeenSent({
+ email_type: 'signup_user',
+ workos_user_id: workosUserId,
+ });
+
+ return memberSent || nonMemberSent || legacySent;
+}
+
+/**
+ * Send signup confirmation email to new users
+ * Content varies based on whether their organization has an active subscription
+ * Now with tracking and duplicate prevention!
+ */
+export async function sendUserSignupEmail(data: {
+ to: string;
+ firstName?: string;
+ organizationName?: string;
+ hasActiveSubscription: boolean;
+ workosUserId?: string;
+ workosOrganizationId?: string;
+}): Promise {
+ if (!resend) {
+ logger.debug('Resend not configured, skipping signup email');
+ return false;
+ }
+
+ // Check if already sent (if we have user ID)
+ if (data.workosUserId) {
+ const alreadySent = await hasSignupEmailBeenSent(data.workosUserId);
+ if (alreadySent) {
+ logger.debug({ userId: data.workosUserId }, 'Signup email already sent to this user, skipping');
+ return true; // Return true since this isn't a failure
+ }
+ }
+
+ const greeting = data.firstName ? `Hi ${data.firstName},` : 'Hi there,';
+ const emailType: EmailType = data.hasActiveSubscription ? 'signup_user_member' : 'signup_user_nonmember';
+
+ // Different content based on subscription status
+ const { subject, ctaText, ctaDestination, ctaLinkName } = data.hasActiveSubscription
+ ? {
+ subject: `Welcome to ${data.organizationName || 'your team'} on AgenticAdvertising.org`,
+ ctaText: 'Go to Dashboard',
+ ctaDestination: 'https://agenticadvertising.org/dashboard',
+ ctaLinkName: 'cta_dashboard',
+ }
+ : {
+ subject: 'Welcome to AgenticAdvertising.org',
+ ctaText: 'Become a Member',
+ ctaDestination: 'https://agenticadvertising.org/dashboard/billing',
+ ctaLinkName: 'cta_billing',
+ };
+
+ try {
+ // Create tracking record
+ const emailEvent = await emailDb.createEmailEvent({
+ email_type: emailType,
+ recipient_email: data.to,
+ subject,
+ workos_user_id: data.workosUserId,
+ workos_organization_id: data.workosOrganizationId,
+ metadata: {
+ firstName: data.firstName,
+ organizationName: data.organizationName,
+ hasActiveSubscription: data.hasActiveSubscription,
+ },
+ });
+
+ const trackingId = emailEvent.tracking_id;
+
+ // Build tracked URLs
+ const ctaUrl = trackedUrl(trackingId, ctaLinkName, ctaDestination);
+
+ // Signup email is transactional - no unsubscribe link
+ // Future marketing emails will include unsubscribe via:
+ // const unsubscribeToken = data.workosUserId ? await getUnsubscribeToken(data.workosUserId, data.to) : null;
+ const footerHtml = generateFooterHtml(trackingId, null);
+ const footerText = generateFooterText(null);
+
+ const mainContent = data.hasActiveSubscription
+ ? `
+ ${greeting}
+
+ You've joined ${data.organizationName || 'your organization'} on AgenticAdvertising.org. Your team is already a member!
+
+ Here's what you can do:
+
+
+ - View the Member Directory - Connect with other members building agentic advertising
+ - Access your Dashboard - Manage your organization's profile and settings
+ - Invite Teammates - Add more people from your organization
+
+
+ Get started by visiting your dashboard:
`
+ : `
+ ${greeting}
+
+ Thanks for signing up for AgenticAdvertising.org${data.organizationName ? ` with ${data.organizationName}` : ''}!
+
+ You've created an account, but your organization isn't a member yet. Membership gives you access to:
+
+
+ - Member Directory - Connect with companies building agentic advertising
+ - Working Groups - Participate in shaping the future of AdCP
+ - Member Profile - Showcase your organization's capabilities
+
+
+ Ready to become a member?
`;
+
+ const { data: sendData, error } = await resend.emails.send({
+ from: FROM_EMAIL,
+ to: data.to,
+ subject,
+ html: `
+
+
+
+
+
+
+
+
+
Welcome!
+
+
+ ${mainContent}
+
+
+ ${ctaText}
+
+
+ If you have any questions, just reply to this email - we're happy to help.
+
+
+ Best,
+ The AgenticAdvertising.org Team
+
+ ${footerHtml}
+
+
+ `.trim(),
+ text: data.hasActiveSubscription
+ ? `Welcome!
+
+${data.firstName ? `Hi ${data.firstName},` : 'Hi there,'}
+
+You've joined ${data.organizationName || 'your organization'} on AgenticAdvertising.org. Your team is already a member!
+
+Here's what you can do:
+- View the Member Directory - Connect with other members building agentic advertising
+- Access your Dashboard - Manage your organization's profile and settings
+- Invite Teammates - Add more people from your organization
+
+Get started by visiting your dashboard:
+https://agenticadvertising.org/dashboard
+
+If you have any questions, just reply to this email - we're happy to help.
+
+Best,
+The AgenticAdvertising.org Team
+
+${footerText}`
+ : `Welcome!
+
+${data.firstName ? `Hi ${data.firstName},` : 'Hi there,'}
+
+Thanks for signing up for AgenticAdvertising.org${data.organizationName ? ` with ${data.organizationName}` : ''}!
+
+You've created an account, but your organization isn't a member yet. Membership gives you access to:
+- Member Directory - Connect with companies building agentic advertising
+- Working Groups - Participate in shaping the future of AdCP
+- Member Profile - Showcase your organization's capabilities
+
+Ready to become a member?
+https://agenticadvertising.org/dashboard/billing
+
+If you have any questions, just reply to this email - we're happy to help.
+
+Best,
+The AgenticAdvertising.org Team
+
+${footerText}`,
+ });
+
+ if (error) {
+ logger.error({ error, to: data.to, trackingId }, 'Failed to send signup email');
+ return false;
+ }
+
+ // Mark as sent
+ await emailDb.markEmailSent(trackingId, sendData?.id);
+
+ logger.info(
+ { to: data.to, hasActiveSubscription: data.hasActiveSubscription, trackingId },
+ 'User signup email sent'
+ );
+ return true;
+ } catch (error) {
+ logger.error({ error, to: data.to }, 'Error sending signup email');
+ return false;
+ }
+}
+
+/**
+ * Send a marketing/campaign email with unsubscribe capability
+ * This is used for newsletters, announcements, etc.
+ */
+export async function sendMarketingEmail(data: {
+ to: string;
+ subject: string;
+ htmlContent: string;
+ textContent: string;
+ category: string;
+ workosUserId: string;
+ workosOrganizationId?: string;
+ campaignId?: string;
+}): Promise {
+ if (!resend) {
+ logger.debug('Resend not configured, skipping marketing email');
+ return false;
+ }
+
+ // Check if user wants to receive this category
+ const shouldSend = await emailPrefsDb.shouldSendEmail({
+ workos_user_id: data.workosUserId,
+ category_id: data.category,
+ });
+
+ if (!shouldSend) {
+ logger.debug({ userId: data.workosUserId, category: data.category }, 'User opted out of category, skipping');
+ return true; // Not a failure, just respecting preferences
+ }
+
+ try {
+ // Get unsubscribe token
+ const unsubscribeToken = await getUnsubscribeToken(data.workosUserId, data.to);
+
+ // Create tracking record
+ const emailEvent = await emailDb.createEmailEvent({
+ email_type: data.category,
+ recipient_email: data.to,
+ subject: data.subject,
+ workos_user_id: data.workosUserId,
+ workos_organization_id: data.workosOrganizationId,
+ metadata: { campaignId: data.campaignId },
+ });
+
+ const trackingId = emailEvent.tracking_id;
+
+ // Generate footer with unsubscribe link
+ const footerHtml = generateFooterHtml(trackingId, unsubscribeToken, data.category);
+ const footerText = generateFooterText(unsubscribeToken, data.category);
+
+ const { data: sendData, error } = await resend.emails.send({
+ from: FROM_EMAIL,
+ to: data.to,
+ subject: data.subject,
+ headers: {
+ 'List-Unsubscribe': `<${BASE_URL}/unsubscribe/${unsubscribeToken}>`,
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
+ },
+ html: `
+
+
+
+
+
+
+
+ ${data.htmlContent}
+ ${footerHtml}
+
+
+ `.trim(),
+ text: `${data.textContent}
+
+${footerText}`,
+ });
+
+ if (error) {
+ logger.error({ error, to: data.to, trackingId }, 'Failed to send marketing email');
+ return false;
+ }
+
+ await emailDb.markEmailSent(trackingId, sendData?.id);
+
+ logger.info({ to: data.to, category: data.category, trackingId }, 'Marketing email sent');
+ return true;
+ } catch (error) {
+ logger.error({ error, to: data.to }, 'Error sending marketing email');
+ return false;
+ }
+}
+
+// Re-export for use in routes
+export { emailDb, emailPrefsDb, getUnsubscribeToken };
diff --git a/server/tests/unit/email-notification.test.ts b/server/tests/unit/email-notification.test.ts
new file mode 100644
index 000000000..7e3f0a28f
--- /dev/null
+++ b/server/tests/unit/email-notification.test.ts
@@ -0,0 +1,340 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+// Create mock send function we can inspect
+const mockSend = vi.fn();
+
+// Mock resend before importing the module - needs to be a proper class
+vi.mock('resend', () => {
+ return {
+ Resend: class MockResend {
+ emails = {
+ send: mockSend,
+ };
+ },
+ };
+});
+
+// Mock the database
+const mockCreateEmailEvent = vi.fn();
+const mockMarkEmailSent = vi.fn();
+const mockHasEmailBeenSent = vi.fn();
+
+vi.mock('../../src/db/email-db.js', () => {
+ return {
+ emailDb: {
+ createEmailEvent: mockCreateEmailEvent,
+ markEmailSent: mockMarkEmailSent,
+ hasEmailBeenSent: mockHasEmailBeenSent,
+ },
+ EmailDatabase: class MockEmailDatabase {},
+ };
+});
+
+// Mock email preferences database
+const mockGetOrCreateUserPreferences = vi.fn();
+const mockShouldSendEmail = vi.fn();
+
+vi.mock('../../src/db/email-preferences-db.js', () => {
+ return {
+ emailPrefsDb: {
+ getOrCreateUserPreferences: mockGetOrCreateUserPreferences,
+ shouldSendEmail: mockShouldSendEmail,
+ },
+ EmailPreferencesDatabase: class MockEmailPreferencesDatabase {},
+ };
+});
+
+describe('Email Notifications', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ vi.resetModules();
+ mockSend.mockReset();
+ mockCreateEmailEvent.mockReset();
+ mockMarkEmailSent.mockReset();
+ mockHasEmailBeenSent.mockReset();
+ mockGetOrCreateUserPreferences.mockReset();
+ mockShouldSendEmail.mockReset();
+
+ mockSend.mockResolvedValue({ data: { id: 'test-email-id' }, error: null });
+ mockCreateEmailEvent.mockResolvedValue({ tracking_id: 'test-tracking-id' });
+ mockMarkEmailSent.mockResolvedValue(undefined);
+ mockHasEmailBeenSent.mockResolvedValue(false);
+ mockGetOrCreateUserPreferences.mockResolvedValue({ unsubscribe_token: 'test-unsub-token' });
+ mockShouldSendEmail.mockResolvedValue(true);
+
+ process.env = { ...originalEnv, RESEND_API_KEY: 'test_api_key' };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ describe('sendWelcomeEmail', () => {
+ it('should send welcome email with correct parameters', async () => {
+ const { sendWelcomeEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendWelcomeEmail({
+ to: 'test@example.com',
+ organizationName: 'Test Org',
+ productName: 'Professional Plan',
+ });
+
+ expect(result).toBe(true);
+ expect(mockCreateEmailEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ email_type: 'welcome_member',
+ recipient_email: 'test@example.com',
+ })
+ );
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: 'test@example.com',
+ subject: 'Welcome to AgenticAdvertising.org!',
+ from: expect.stringContaining('hello@updates.agenticadvertising.org'),
+ })
+ );
+ expect(mockMarkEmailSent).toHaveBeenCalledWith('test-tracking-id', 'test-email-id');
+ });
+
+ it('should include organization name in email body', async () => {
+ const { sendWelcomeEmail } = await import('../../src/notifications/email.js');
+
+ await sendWelcomeEmail({
+ to: 'test@example.com',
+ organizationName: 'Acme Corp',
+ });
+
+ const sendCall = mockSend.mock.calls[0]?.[0];
+ expect(sendCall?.html).toContain('Acme Corp');
+ expect(sendCall?.text).toContain('Acme Corp');
+ });
+
+ it('should include tracked URLs in email', async () => {
+ const { sendWelcomeEmail } = await import('../../src/notifications/email.js');
+
+ await sendWelcomeEmail({
+ to: 'test@example.com',
+ organizationName: 'Test Org',
+ });
+
+ const sendCall = mockSend.mock.calls[0]?.[0];
+ // Tracked URLs contain the tracking ID and redirect info
+ expect(sendCall?.html).toContain('/r/test-tracking-id');
+ expect(sendCall?.html).toContain('cta_dashboard');
+ });
+
+ it('should return false when Resend API key is not configured', async () => {
+ // Reset and import without API key
+ vi.resetModules();
+ process.env = { ...originalEnv };
+ delete process.env.RESEND_API_KEY;
+
+ const { sendWelcomeEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendWelcomeEmail({
+ to: 'test@example.com',
+ organizationName: 'Test Org',
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when send fails with error', async () => {
+ mockSend.mockResolvedValue({
+ data: null,
+ error: { message: 'Invalid API key' },
+ });
+
+ const { sendWelcomeEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendWelcomeEmail({
+ to: 'test@example.com',
+ organizationName: 'Test Org',
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when send throws exception', async () => {
+ mockSend.mockRejectedValue(new Error('Network error'));
+
+ const { sendWelcomeEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendWelcomeEmail({
+ to: 'test@example.com',
+ organizationName: 'Test Org',
+ });
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('sendUserSignupEmail', () => {
+ it('should send email with member content for users with active subscription', async () => {
+ const { sendUserSignupEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendUserSignupEmail({
+ to: 'test@example.com',
+ firstName: 'John',
+ organizationName: 'Acme Corp',
+ hasActiveSubscription: true,
+ });
+
+ expect(result).toBe(true);
+ const sendCall = mockSend.mock.calls[0]?.[0];
+ expect(sendCall?.subject).toContain('Acme Corp');
+ expect(sendCall?.subject).toContain('AgenticAdvertising.org');
+ expect(sendCall?.html).toContain('Hi John,');
+ expect(sendCall?.html).toContain('already a member!');
+ expect(sendCall?.html).toContain('Invite Teammates');
+ });
+
+ it('should send email with non-member content for users without subscription', async () => {
+ const { sendUserSignupEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendUserSignupEmail({
+ to: 'test@example.com',
+ firstName: 'Jane',
+ organizationName: 'Startup Inc',
+ hasActiveSubscription: false,
+ });
+
+ expect(result).toBe(true);
+ const sendCall = mockSend.mock.calls[0]?.[0];
+ expect(sendCall?.subject).toBe('Welcome to AgenticAdvertising.org');
+ expect(sendCall?.html).toContain('Hi Jane,');
+ expect(sendCall?.html).toContain("isn't a member yet");
+ expect(sendCall?.html).toContain('Become a Member');
+ });
+
+ it('should handle missing firstName gracefully', async () => {
+ const { sendUserSignupEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendUserSignupEmail({
+ to: 'test@example.com',
+ hasActiveSubscription: false,
+ });
+
+ expect(result).toBe(true);
+ const sendCall = mockSend.mock.calls[0]?.[0];
+ expect(sendCall?.html).toContain('Hi there,');
+ });
+
+ it('should skip sending if email already sent to user', async () => {
+ mockHasEmailBeenSent.mockResolvedValue(true);
+
+ const { sendUserSignupEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendUserSignupEmail({
+ to: 'test@example.com',
+ hasActiveSubscription: true,
+ workosUserId: 'user_123',
+ });
+
+ // Should return true (success) but not actually send
+ expect(result).toBe(true);
+ expect(mockSend).not.toHaveBeenCalled();
+ });
+
+ it('should track email type based on subscription status', async () => {
+ const { sendUserSignupEmail } = await import('../../src/notifications/email.js');
+
+ // Member email
+ await sendUserSignupEmail({
+ to: 'member@example.com',
+ hasActiveSubscription: true,
+ });
+
+ expect(mockCreateEmailEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ email_type: 'signup_user_member',
+ })
+ );
+
+ // Reset mocks
+ mockCreateEmailEvent.mockClear();
+ mockSend.mockClear();
+ vi.resetModules();
+
+ // Re-import to get fresh module
+ const { sendUserSignupEmail: sendEmail2 } = await import('../../src/notifications/email.js');
+
+ // Non-member email
+ await sendEmail2({
+ to: 'nonmember@example.com',
+ hasActiveSubscription: false,
+ });
+
+ expect(mockCreateEmailEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ email_type: 'signup_user_nonmember',
+ })
+ );
+ });
+
+ it('should return false when Resend API key is not configured', async () => {
+ vi.resetModules();
+ process.env = { ...originalEnv };
+ delete process.env.RESEND_API_KEY;
+
+ const { sendUserSignupEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendUserSignupEmail({
+ to: 'test@example.com',
+ hasActiveSubscription: true,
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when send fails', async () => {
+ mockSend.mockResolvedValue({
+ data: null,
+ error: { message: 'Send failed' },
+ });
+
+ const { sendUserSignupEmail } = await import('../../src/notifications/email.js');
+
+ const result = await sendUserSignupEmail({
+ to: 'test@example.com',
+ hasActiveSubscription: true,
+ });
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('hasSignupEmailBeenSent', () => {
+ it('should return true if member signup email was sent', async () => {
+ mockHasEmailBeenSent.mockImplementation(({ email_type }) => {
+ return Promise.resolve(email_type === 'signup_user_member');
+ });
+
+ const { hasSignupEmailBeenSent } = await import('../../src/notifications/email.js');
+
+ const result = await hasSignupEmailBeenSent('user_123');
+ expect(result).toBe(true);
+ });
+
+ it('should return true if non-member signup email was sent', async () => {
+ mockHasEmailBeenSent.mockImplementation(({ email_type }) => {
+ return Promise.resolve(email_type === 'signup_user_nonmember');
+ });
+
+ const { hasSignupEmailBeenSent } = await import('../../src/notifications/email.js');
+
+ const result = await hasSignupEmailBeenSent('user_123');
+ expect(result).toBe(true);
+ });
+
+ it('should return false if no signup email was sent', async () => {
+ mockHasEmailBeenSent.mockResolvedValue(false);
+
+ const { hasSignupEmailBeenSent } = await import('../../src/notifications/email.js');
+
+ const result = await hasSignupEmailBeenSent('user_123');
+ expect(result).toBe(false);
+ });
+ });
+});