Compare commits

...

7 Commits

Author SHA1 Message Date
Jeff Emmett 551ae0d217 Remove hardcoded DB password default from docker-compose
All secrets now come from .env file on the server.
No default/fallback values for any credentials.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 00:50:04 +00:00
Jeff Emmett f59b3efb81 Switch n8n email from Resend API to Mailcow SMTP
Replace all Resend HTTP API calls with n8n built-in emailSend
nodes using Mailcow SMTP at mx.jeffemmett.com:587 (STARTTLS).
Add SMTP env vars and n8n API key to docker-compose config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 00:30:05 +00:00
Jeff Emmett e929c4563e docs(backlog): initialize backlog and add infrastructure task (task-1)
Set up backlog tracking for CosmoLocal. Task-1 documents completed
infrastructure: Mailcow SMTP, Docmost workspace, Listmonk newsletter
list with per-list RBAC for Bryan, and email DNS authentication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 00:20:52 +00:00
Jeff Emmett dc55322103 Remove hardcoded API keys and credentials from tracked files
Move RESEND_API_KEY and Listmonk credentials to .env file
on the server. No secrets should be committed to the repo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 12:19:40 +00:00
Jeff Emmett c257f830c9 Add API keys as Docker env vars for n8n community edition
n8n community edition doesn't have Settings > Variables.
Pass TWENTY_API_KEY and RESEND_API_KEY as container
environment variables instead, accessible via $env.* in workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:35:20 +00:00
Jeff Emmett 7c01037970 Add 5 n8n CRM automation workflows
- 01: Contact intake webhook → Twenty CRM
- 02: Lead nurturing 3-email sequence via Resend
- 03: Daily CRM → Listmonk newsletter sync
- 04: Weekly stale contact follow-up reminders
- 05: Gitea/GitHub webhook events → CRM activity log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:20:28 +00:00
Jeff Emmett 6b578d2c7f Add n8n instance at automate.cosmolocal.world
Adds n8n workflow automation with PostgreSQL backend,
Traefik routing for automate.cosmolocal.world, and
isolated internal network for the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:56:25 +00:00
9 changed files with 1529 additions and 0 deletions

5
backlog/config.yml Normal file
View File

@ -0,0 +1,5 @@
project_name: CosmoLocal Website
project_id: cosmolocal-website
description: CosmoLocal World website and related infrastructure
created: '2026-02-09'
integration_mode: mcp

View File

@ -0,0 +1,65 @@
---
id: task-1
title: Set up CosmoLocal email, docs, and newsletter infrastructure
status: Done
assignee: ['@claude']
created_date: '2026-02-09 12:00'
updated_date: '2026-02-09 21:30'
labels: [infrastructure, email, docs, newsletter]
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Set up CosmoLocal World infrastructure: Mailcow SMTP for cosmolocal.world, Docmost workspace at docs.cosmolocal.world, Listmonk newsletter list with per-list RBAC for Bryan, and email authentication (DKIM, SPF, DMARC).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Configure Mailcow SMTP for cosmolocal.world domain
- [x] #2 Set up DNS records (SPF, DKIM, DMARC) for cosmolocal.world
- [x] #3 Create noreply@cosmolocal.world mailbox with newsletter@ alias
- [x] #4 Deploy Docmost at docs.cosmolocal.world (separate workspace, shared infra)
- [x] #5 Configure SMTP for Docmost CosmoLocal instance
- [x] #6 Create CosmoLocal World list in Listmonk
- [x] #7 Set up Bryan as editor with per-list RBAC (CosmoLocal list only)
- [x] #8 Set up Google Postmaster Tools for cosmolocal.world
- [x] #9 Configure Traefik websecure + Let's Encrypt for docs.cosmolocal.world
- [x] #10 Invite Bryan to CosmoLocal Docmost workspace
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
### Mailcow (cosmolocal.world)
- Mailbox: noreply@cosmolocal.world
- Alias: newsletter@cosmolocal.world → noreply@cosmolocal.world (sender_allowed=1)
- SMTP: mx.jeffemmett.com:465 (TLS)
- DNS: SPF, DKIM (2048-bit), DMARC all configured on Cloudflare
- Google Postmaster Tools verified
### Docmost (docs.cosmolocal.world)
- Separate Docmost app container (docmost-cl) sharing Postgres + Redis with docs.jeffemmett.com
- Database: docmost_cosmolocal (in shared docmost-db Postgres)
- Redis: db 1 (shared docmost-redis)
- DNS: proxied A record → 159.195.32.209
- SSL: Traefik websecure entrypoint + Let's Encrypt cert
- SMTP: noreply@cosmolocal.world via Mailcow
- Location: /opt/apps/docmost/docker-compose.yml (single compose file)
### Listmonk (newsletter.cosmolocal.world)
- CosmoLocal World list created (id=21, public, single opt-in)
- SMTP server "cosmolocal.world" configured in Listmonk settings
- Bryan's account: bryan / CosmoLocal-e2dc5eec
- User role: Editor (campaigns, subscribers, templates, media - no admin)
- List role: CosmoLocal Editor (scoped to CosmoLocal World list only)
- Cannot see other lists, settings, users, or roles
### Bryan's Access Summary
| Service | URL | Username | Role |
|---------|-----|----------|------|
| Listmonk | newsletter.jeffemmett.com/admin | bryan | Editor (CosmoLocal list only) |
| Docmost | docs.cosmolocal.world | bryan@cosmolocal.world | Member (invited) |
<!-- SECTION:NOTES:END -->

View File

@ -15,6 +15,72 @@ services:
networks:
- traefik-public
n8n-cosmolocal:
image: n8nio/n8n:latest
container_name: n8n-cosmolocal
restart: unless-stopped
environment:
- N8N_HOST=automate.cosmolocal.world
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://automate.cosmolocal.world/
- GENERIC_TIMEZONE=Europe/Brussels
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=n8n-cosmolocal-db
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD}
- TWENTY_API_KEY=${TWENTY_API_KEY}
- LISTMONK_CREDENTIALS=${LISTMONK_CREDENTIALS}
- N8N_SMTP_HOST=mx.jeffemmett.com
- N8N_SMTP_PORT=587
- N8N_SMTP_USER=${SMTP_USER}
- N8N_SMTP_PASS=${SMTP_PASS}
- N8N_SMTP_SENDER=${SMTP_SENDER:-hello@cosmolocal.world}
- N8N_EMAIL_MODE=smtp
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- n8n-cosmolocal-data:/home/node/.n8n
labels:
- "traefik.enable=true"
- "traefik.http.routers.n8n-cosmolocal.rule=Host(`automate.cosmolocal.world`)"
- "traefik.http.routers.n8n-cosmolocal.entrypoints=web"
- "traefik.http.services.n8n-cosmolocal.loadbalancer.server.port=5678"
- "traefik.http.routers.n8n-cosmolocal-secure.rule=Host(`automate.cosmolocal.world`)"
- "traefik.http.routers.n8n-cosmolocal-secure.entrypoints=websecure"
- "traefik.http.routers.n8n-cosmolocal-secure.tls=true"
- "traefik.http.routers.n8n-cosmolocal-secure.service=n8n-cosmolocal"
depends_on:
n8n-cosmolocal-db:
condition: service_healthy
networks:
- traefik-public
- cosmolocal-internal
n8n-cosmolocal-db:
image: postgres:16-alpine
container_name: n8n-cosmolocal-db
restart: unless-stopped
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${N8N_DB_PASSWORD}
volumes:
- n8n-cosmolocal-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n"]
interval: 10s
timeout: 5s
retries: 5
networks:
- cosmolocal-internal
volumes:
n8n-cosmolocal-data:
n8n-cosmolocal-db:
networks:
traefik-public:
external: true
cosmolocal-internal:

View File

@ -0,0 +1,207 @@
{
"name": "Contact Intake — Form to CRM",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "contact-intake",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-trigger",
"name": "Webhook — Contact Form",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "contact-intake"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "check-email",
"leftValue": "={{ $json.body.email }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "isNotEmpty"
}
},
{
"id": "check-name",
"leftValue": "={{ $json.body.name }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "isNotEmpty"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "validate-input",
"name": "Validate Input",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [460, 300]
},
{
"parameters": {
"method": "POST",
"url": "=https://crm.cosmolocal.world/api/v1/objects/people",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"name\": {\n \"firstName\": \"{{ $json.body.name.split(' ')[0] }}\",\n \"lastName\": \"{{ $json.body.name.split(' ').slice(1).join(' ') || '' }}\"\n },\n \"emails\": {\n \"primaryEmail\": \"{{ $json.body.email }}\"\n },\n \"phones\": {\n \"primaryPhone\": \"{{ $json.body.phone || '' }}\"\n }\n}",
"options": {}
},
"id": "create-crm-contact",
"name": "Create Contact in Twenty CRM",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [680, 240]
},
{
"parameters": {
"method": "POST",
"url": "=https://crm.cosmolocal.world/api/v1/objects/notes",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"title\": \"Contact Form Submission\",\n \"body\": \"{{ $('Webhook — Contact Form').item.json.body.message || 'No message provided' }}\",\n \"noteTargets\": [{\n \"personId\": \"{{ $json.data.id }}\"\n }]\n}",
"options": {}
},
"id": "add-note",
"name": "Add Note to Contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [900, 240]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={ \"success\": true, \"message\": \"Contact created successfully\" }",
"options": {
"responseCode": 200
}
},
"id": "respond-success",
"name": "Respond — Success",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1120, 240]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={ \"success\": false, \"message\": \"Missing required fields: name and email\" }",
"options": {
"responseCode": 400
}
},
"id": "respond-error",
"name": "Respond — Validation Error",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [680, 420]
}
],
"connections": {
"Webhook — Contact Form": {
"main": [
[
{
"node": "Validate Input",
"type": "main",
"index": 0
}
]
]
},
"Validate Input": {
"main": [
[
{
"node": "Create Contact in Twenty CRM",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond — Validation Error",
"type": "main",
"index": 0
}
]
]
},
"Create Contact in Twenty CRM": {
"main": [
[
{
"node": "Add Note to Contact",
"type": "main",
"index": 0
}
]
]
},
"Add Note to Contact": {
"main": [
[
{
"node": "Respond — Success",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "cosmolocal"
},
{
"name": "crm"
}
],
"pinData": {}
}

View File

@ -0,0 +1,257 @@
{
"name": "Lead Nurturing — Welcome Email Sequence",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "new-contact-created",
"options": {}
},
"id": "webhook-new-contact",
"name": "Webhook — New Contact Created",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "new-contact-created"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "set-email",
"name": "email",
"value": "={{ $json.body.email || $json.body.emails?.primaryEmail }}",
"type": "string"
},
{
"id": "set-first-name",
"name": "firstName",
"value": "={{ $json.body.firstName || $json.body.name?.firstName || 'there' }}",
"type": "string"
},
{
"id": "set-contact-id",
"name": "contactId",
"value": "={{ $json.body.id || $json.body.contactId }}",
"type": "string"
}
]
},
"options": {}
},
"id": "extract-fields",
"name": "Extract Contact Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [460, 300]
},
{
"parameters": {
"fromEmail": "={{ $env.N8N_SMTP_SENDER }}",
"toEmail": "={{ $json.email }}",
"subject": "Welcome to the Cosmolocal Foundation",
"emailType": "html",
"html": "<h2>Welcome, {{ $json.firstName }}!</h2><p>Thank you for connecting with the Cosmolocal Foundation. We're building a world where local communities thrive within regenerative economies, connected through global knowledge-sharing and commons-based collaboration.</p><p><strong>\"What is heavy should be local, and what is light should be global and shared.\"</strong></p><p>Here's what we're working on:</p><ul><li><strong>Decentralized Governance</strong> — Transparent, community-led decision-making</li><li><strong>Open Knowledge Commons</strong> — Sharing sustainable production methods globally</li><li><strong>Commons-Compatible Capital</strong> — Financing transformative local projects</li><li><strong>Cosmolocal Coordination</strong> — Connecting local efforts into a global network</li></ul><p>We'll be in touch with updates on our progress and opportunities to get involved.</p><p>Warm regards,<br/>The Cosmolocal Foundation Team</p><p style='color:#78716c;font-size:12px;'>cosmolocal.world</p>",
"options": {}
},
"id": "send-welcome-email",
"name": "Send Welcome Email (Day 0)",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2.1,
"position": [680, 300],
"credentials": {
"smtp": {
"id": "mailcow-smtp",
"name": "Mailcow SMTP"
}
}
},
{
"parameters": {
"amount": 3,
"unit": "days"
},
"id": "wait-3-days",
"name": "Wait 3 Days",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [900, 300]
},
{
"parameters": {
"fromEmail": "={{ $env.N8N_SMTP_SENDER }}",
"toEmail": "={{ $('Extract Contact Fields').item.json.email }}",
"subject": "Our Strategic Priorities — How We're Building Change",
"emailType": "html",
"html": "<h2>Hi {{ $('Extract Contact Fields').item.json.firstName }},</h2><p>We wanted to share more about our strategic approach to systemic transformation.</p><p>The Cosmolocal Foundation is executing on eight key initiatives:</p><ol><li><strong>Mapping Regenerative Communities</strong> — Cataloging eco-villages, circular economy hubs, and governance initiatives worldwide</li><li><strong>Web3 Funding</strong> — Quadratic funding, DAOs, and Collaborative Finance for local projects</li><li><strong>Open Resources</strong> — A global knowledge commons of governance models and production blueprints</li><li><strong>Education & Advocacy</strong> — Engaging policymakers to scale cosmolocal principles</li><li><strong>Pilots & Grants</strong> — Funding projects that demonstrate scalable solutions</li><li><strong>Cosmolocal Certification</strong> — Blockchain-verified standards for cosmolocal initiatives</li><li><strong>Global Alliances</strong> — Bioregional collaboration on governance and mutual aid</li><li><strong>Impact Research</strong> — Evidence-based scaling through the Cosmolocal Research Institute</li></ol><p>Want to learn more or get involved? Reply to this email — we'd love to hear from you.</p><p>Best,<br/>The Cosmolocal Foundation Team</p>",
"options": {}
},
"id": "send-followup-email",
"name": "Send Follow-up Email (Day 3)",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2.1,
"position": [1120, 300],
"credentials": {
"smtp": {
"id": "mailcow-smtp",
"name": "Mailcow SMTP"
}
}
},
{
"parameters": {
"amount": 7,
"unit": "days"
},
"id": "wait-7-days",
"name": "Wait 7 Days",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [1340, 300]
},
{
"parameters": {
"fromEmail": "={{ $env.N8N_SMTP_SENDER }}",
"toEmail": "={{ $('Extract Contact Fields').item.json.email }}",
"subject": "Join the Movement — Ways to Participate",
"emailType": "html",
"html": "<h2>Hi {{ $('Extract Contact Fields').item.json.firstName }},</h2><p>We believe in the power of collaboration. Here are ways you can participate in the cosmolocal movement:</p><ul><li><strong>Subscribe to our newsletter</strong> — Stay updated on projects, pilots, and governance experiments</li><li><strong>Join a working group</strong> — Contribute your expertise to research, technology, or community building</li><li><strong>Support a pilot project</strong> — Help fund or participate in cosmolocal demonstrations</li><li><strong>Spread the word</strong> — Share our vision with your network</li></ul><p>Visit <a href='https://cosmolocal.world'>cosmolocal.world</a> to explore our work, or simply reply to this email to start a conversation.</p><p>Together we can build regenerative economies that serve communities and the planet.</p><p>In solidarity,<br/>The Cosmolocal Foundation Team</p>",
"options": {}
},
"id": "send-engagement-email",
"name": "Send Engagement Email (Day 10)",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2.1,
"position": [1560, 300],
"credentials": {
"smtp": {
"id": "mailcow-smtp",
"name": "Mailcow SMTP"
}
}
},
{
"parameters": {
"method": "PATCH",
"url": "=https://crm.cosmolocal.world/api/v1/objects/people/{{ $('Extract Contact Fields').item.json.contactId }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"stage\": \"NURTURE_COMPLETE\"\n}",
"options": {
"ignore_ssl_issues": false
}
},
"id": "update-crm-stage",
"name": "Update CRM — Nurture Complete",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1780, 300]
}
],
"connections": {
"Webhook — New Contact Created": {
"main": [
[
{
"node": "Extract Contact Fields",
"type": "main",
"index": 0
}
]
]
},
"Extract Contact Fields": {
"main": [
[
{
"node": "Send Welcome Email (Day 0)",
"type": "main",
"index": 0
}
]
]
},
"Send Welcome Email (Day 0)": {
"main": [
[
{
"node": "Wait 3 Days",
"type": "main",
"index": 0
}
]
]
},
"Wait 3 Days": {
"main": [
[
{
"node": "Send Follow-up Email (Day 3)",
"type": "main",
"index": 0
}
]
]
},
"Send Follow-up Email (Day 3)": {
"main": [
[
{
"node": "Wait 7 Days",
"type": "main",
"index": 0
}
]
]
},
"Wait 7 Days": {
"main": [
[
{
"node": "Send Engagement Email (Day 10)",
"type": "main",
"index": 0
}
]
]
},
"Send Engagement Email (Day 10)": {
"main": [
[
{
"node": "Update CRM — Nurture Complete",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "cosmolocal"
},
{
"name": "email"
}
],
"pinData": {}
}

View File

@ -0,0 +1,284 @@
{
"name": "Newsletter Sync — CRM to Listmonk",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 6,
"triggerAtMinute": 0
}
]
}
},
"id": "schedule-trigger",
"name": "Daily Sync (6 AM)",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [240, 300]
},
{
"parameters": {
"method": "GET",
"url": "https://crm.cosmolocal.world/api/v1/objects/people",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
}
]
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "limit",
"value": "100"
}
]
},
"options": {}
},
"id": "fetch-crm-contacts",
"name": "Fetch CRM Contacts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [460, 300]
},
{
"parameters": {
"fieldToSplitOut": "data.people",
"options": {}
},
"id": "split-contacts",
"name": "Split Into Items",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [680, 300]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "has-email",
"leftValue": "={{ $json.emails?.primaryEmail }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "isNotEmpty"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "filter-with-email",
"name": "Filter — Has Email",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [900, 300]
},
{
"parameters": {
"method": "GET",
"url": "=http://listmonk-cosmolocal:9000/api/subscribers",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Basic {{ Buffer.from($env.LISTMONK_CREDENTIALS || 'admin:changeme').toString('base64') }}"
}
]
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "query",
"value": "=subscribers.email = '{{ $json.emails.primaryEmail }}'"
},
{
"name": "page",
"value": "1"
},
{
"name": "per_page",
"value": "1"
}
]
},
"options": {}
},
"id": "check-listmonk-exists",
"name": "Check If Subscriber Exists",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1120, 240]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "not-exists",
"leftValue": "={{ $json.data?.total }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-new-subscriber",
"name": "If New Subscriber",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1340, 240]
},
{
"parameters": {
"method": "POST",
"url": "http://listmonk-cosmolocal:9000/api/subscribers",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Basic {{ Buffer.from($env.LISTMONK_CREDENTIALS || 'admin:changeme').toString('base64') }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"email\": \"{{ $('Filter — Has Email').item.json.emails.primaryEmail }}\",\n \"name\": \"{{ $('Filter — Has Email').item.json.name?.firstName || '' }} {{ $('Filter — Has Email').item.json.name?.lastName || '' }}\",\n \"status\": \"enabled\",\n \"lists\": [1],\n \"preconfirm_subscriptions\": true,\n \"attribs\": {\n \"source\": \"twenty-crm-sync\",\n \"crm_id\": \"{{ $('Filter — Has Email').item.json.id }}\"\n }\n}",
"options": {}
},
"id": "create-subscriber",
"name": "Create Listmonk Subscriber",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1560, 180]
},
{
"parameters": {},
"id": "no-op-exists",
"name": "Already Subscribed — Skip",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1560, 360]
}
],
"connections": {
"Daily Sync (6 AM)": {
"main": [
[
{
"node": "Fetch CRM Contacts",
"type": "main",
"index": 0
}
]
]
},
"Fetch CRM Contacts": {
"main": [
[
{
"node": "Split Into Items",
"type": "main",
"index": 0
}
]
]
},
"Split Into Items": {
"main": [
[
{
"node": "Filter — Has Email",
"type": "main",
"index": 0
}
]
]
},
"Filter — Has Email": {
"main": [
[
{
"node": "Check If Subscriber Exists",
"type": "main",
"index": 0
}
],
[]
]
},
"Check If Subscriber Exists": {
"main": [
[
{
"node": "If New Subscriber",
"type": "main",
"index": 0
}
]
]
},
"If New Subscriber": {
"main": [
[
{
"node": "Create Listmonk Subscriber",
"type": "main",
"index": 0
}
],
[
{
"node": "Already Subscribed — Skip",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "cosmolocal"
},
{
"name": "newsletter"
}
],
"pinData": {}
}

View File

@ -0,0 +1,230 @@
{
"name": "Follow-up Reminders — Stale Contacts",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 9,
"triggerAtMinute": 0,
"triggerAtDay": 1
}
]
}
},
"id": "weekly-schedule",
"name": "Weekly Check (Monday 9 AM)",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [240, 300]
},
{
"parameters": {
"method": "GET",
"url": "https://crm.cosmolocal.world/api/v1/objects/people",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
}
]
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "limit",
"value": "100"
}
]
},
"options": {}
},
"id": "fetch-all-contacts",
"name": "Fetch All Contacts",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [460, 300]
},
{
"parameters": {
"fieldToSplitOut": "data.people",
"options": {}
},
"id": "split-contacts",
"name": "Split Into Items",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [680, 300]
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst staleItems = [];\nconst now = new Date();\nconst STALE_DAYS = 14;\n\nfor (const item of items) {\n const updatedAt = new Date(item.json.updatedAt);\n const daysSinceUpdate = Math.floor((now - updatedAt) / (1000 * 60 * 60 * 24));\n \n if (daysSinceUpdate >= STALE_DAYS) {\n staleItems.push({\n json: {\n ...item.json,\n daysSinceUpdate,\n email: item.json.emails?.primaryEmail || 'N/A',\n fullName: `${item.json.name?.firstName || ''} ${item.json.name?.lastName || ''}`.trim() || 'Unknown'\n }\n });\n }\n}\n\nreturn staleItems.length > 0 ? staleItems : [{ json: { noStaleContacts: true } }];"
},
"id": "filter-stale",
"name": "Filter Stale Contacts (14+ days)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 300]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "has-stale",
"leftValue": "={{ $json.noStaleContacts }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "notTrue"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-has-stale",
"name": "Any Stale Contacts?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1120, 300]
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst count = items.length;\n\nconst tableRows = items.map(i => `<tr><td style=\"padding:8px;border:1px solid #e7e5e4\">${i.json.fullName}</td><td style=\"padding:8px;border:1px solid #e7e5e4\">${i.json.email}</td><td style=\"padding:8px;border:1px solid #e7e5e4\">${i.json.daysSinceUpdate}</td></tr>`).join('');\n\nreturn [{\n json: {\n subject: `[Cosmolocal CRM] ${count} contact${count !== 1 ? 's' : ''} need${count === 1 ? 's' : ''} follow-up`,\n htmlBody: `<h2>Stale Contact Report</h2><p>The following ${count} contact${count !== 1 ? 's have' : ' has'} not been updated in 14+ days:</p><table style=\"border-collapse:collapse;width:100%\"><tr style=\"background:#f5f5f4\"><th style=\"padding:8px;text-align:left;border:1px solid #e7e5e4\">Name</th><th style=\"padding:8px;text-align:left;border:1px solid #e7e5e4\">Email</th><th style=\"padding:8px;text-align:left;border:1px solid #e7e5e4\">Days Stale</th></tr>${tableRows}</table><p style=\"margin-top:16px\"><a href=\"https://crm.cosmolocal.world\">Open CRM</a> to follow up.</p><p style=\"color:#78716c;font-size:12px\">Automated by n8n — automate.cosmolocal.world</p>`,\n count\n }\n}];"
},
"id": "build-report",
"name": "Build Report Email",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1340, 240]
},
{
"parameters": {
"fromEmail": "={{ $env.N8N_SMTP_SENDER }}",
"toEmail": "hello@cosmolocal.world",
"subject": "={{ $json.subject }}",
"emailType": "html",
"html": "={{ $json.htmlBody }}",
"options": {}
},
"id": "send-reminder-email",
"name": "Send Reminder to Team",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2.1,
"position": [1560, 240],
"credentials": {
"smtp": {
"id": "mailcow-smtp",
"name": "Mailcow SMTP"
}
}
},
{
"parameters": {},
"id": "no-stale-contacts",
"name": "No Stale Contacts — Done",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1340, 420]
}
],
"connections": {
"Weekly Check (Monday 9 AM)": {
"main": [
[
{
"node": "Fetch All Contacts",
"type": "main",
"index": 0
}
]
]
},
"Fetch All Contacts": {
"main": [
[
{
"node": "Split Into Items",
"type": "main",
"index": 0
}
]
]
},
"Split Into Items": {
"main": [
[
{
"node": "Filter Stale Contacts (14+ days)",
"type": "main",
"index": 0
}
]
]
},
"Filter Stale Contacts (14+ days)": {
"main": [
[
{
"node": "Any Stale Contacts?",
"type": "main",
"index": 0
}
]
]
},
"Any Stale Contacts?": {
"main": [
[
{
"node": "Build Report Email",
"type": "main",
"index": 0
}
],
[
{
"node": "No Stale Contacts — Done",
"type": "main",
"index": 0
}
]
]
},
"Build Report Email": {
"main": [
[
{
"node": "Send Reminder to Team",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "cosmolocal"
},
{
"name": "crm"
}
],
"pinData": {}
}

View File

@ -0,0 +1,347 @@
{
"name": "Webhook Events — Gitea/GitHub to CRM",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "git-events",
"options": {}
},
"id": "webhook-git",
"name": "Webhook — Git Events",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300],
"webhookId": "git-events"
},
{
"parameters": {
"jsCode": "const body = $input.first().json.body;\nconst headers = $input.first().json.headers;\n\n// Detect source (Gitea vs GitHub)\nconst isGitea = headers['x-gitea-event'] !== undefined;\nconst isGitHub = headers['x-github-event'] !== undefined;\n\nconst eventType = isGitea \n ? headers['x-gitea-event'] \n : headers['x-github-event'] || 'unknown';\n\nlet result = {\n source: isGitea ? 'gitea' : isGitHub ? 'github' : 'unknown',\n eventType,\n repo: body.repository?.full_name || body.repository?.name || 'unknown',\n action: body.action || 'push',\n sender: body.sender?.login || body.pusher?.name || 'unknown',\n senderEmail: body.sender?.email || body.pusher?.email || '',\n url: '',\n title: '',\n description: ''\n};\n\nswitch (eventType) {\n case 'push':\n const commits = body.commits || [];\n result.title = `Push: ${commits.length} commit(s) to ${result.repo}`;\n result.description = commits.map(c => `- ${c.message}`).join('\\n');\n result.url = body.compare_url || body.compare || '';\n break;\n case 'issues':\n result.title = `Issue ${body.action}: ${body.issue?.title}`;\n result.description = body.issue?.body || '';\n result.url = body.issue?.html_url || '';\n break;\n case 'pull_request':\n result.title = `PR ${body.action}: ${body.pull_request?.title}`;\n result.description = body.pull_request?.body || '';\n result.url = body.pull_request?.html_url || '';\n break;\n case 'create':\n result.title = `Created ${body.ref_type}: ${body.ref}`;\n result.description = `New ${body.ref_type} created in ${result.repo}`;\n break;\n case 'star':\n case 'watch':\n result.title = `${result.sender} starred ${result.repo}`;\n result.description = `Repository now has ${body.repository?.stars_count || body.repository?.stargazers_count || '?'} stars`;\n break;\n default:\n result.title = `${eventType} event on ${result.repo}`;\n result.description = JSON.stringify(body).substring(0, 500);\n}\n\nreturn [{ json: result }];"
},
"id": "parse-event",
"name": "Parse Git Event",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [460, 300]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "is-significant",
"leftValue": "={{ $json.eventType }}",
"rightValue": "push,issues,pull_request,star",
"operator": {
"type": "string",
"operation": "contains"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "filter-significant",
"name": "Filter Significant Events",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [680, 300]
},
{
"parameters": {
"method": "POST",
"url": "https://crm.cosmolocal.world/api/v1/objects/notes",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"title\": \"[{{ $json.source }}] {{ $json.title }}\",\n \"body\": \"**Event**: {{ $json.eventType }}\\n**Repo**: {{ $json.repo }}\\n**By**: {{ $json.sender }}\\n\\n{{ $json.description }}\\n\\n{{ $json.url ? '[View on ' + $json.source + '](' + $json.url + ')' : '' }}\"\n}",
"options": {}
},
"id": "log-to-crm",
"name": "Log Activity to CRM",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [900, 240]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "has-email",
"leftValue": "={{ $('Parse Git Event').item.json.senderEmail }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "isNotEmpty"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "has-sender-email",
"name": "Sender Has Email?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1120, 240]
},
{
"parameters": {
"method": "GET",
"url": "https://crm.cosmolocal.world/api/v1/objects/people",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
}
]
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "filter",
"value": "={\"emails\":{\"primaryEmail\":{\"eq\":\"{{ $('Parse Git Event').item.json.senderEmail }}\"}}}"
}
]
},
"options": {}
},
"id": "find-contact",
"name": "Find Contact by Email",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1340, 180]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "contact-found",
"leftValue": "={{ $json.data?.people?.length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "contact-exists",
"name": "Contact Found?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1560, 180]
},
{
"parameters": {
"method": "PATCH",
"url": "=https://crm.cosmolocal.world/api/v1/objects/people/{{ $json.data.people[0].id }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"updatedAt\": \"{{ new Date().toISOString() }}\"\n}",
"options": {}
},
"id": "touch-contact",
"name": "Touch Contact — Update Timestamp",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1780, 120]
},
{
"parameters": {
"method": "POST",
"url": "https://crm.cosmolocal.world/api/v1/objects/people",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer {{ $env.TWENTY_API_KEY }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"name\": {\n \"firstName\": \"{{ $('Parse Git Event').item.json.sender }}\",\n \"lastName\": \"\"\n },\n \"emails\": {\n \"primaryEmail\": \"{{ $('Parse Git Event').item.json.senderEmail }}\"\n }\n}",
"options": {}
},
"id": "create-contributor-contact",
"name": "Create Contributor Contact",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1780, 280]
},
{
"parameters": {},
"id": "no-op-skip",
"name": "Skip — Not Significant",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [900, 420]
}
],
"connections": {
"Webhook — Git Events": {
"main": [
[
{
"node": "Parse Git Event",
"type": "main",
"index": 0
}
]
]
},
"Parse Git Event": {
"main": [
[
{
"node": "Filter Significant Events",
"type": "main",
"index": 0
}
]
]
},
"Filter Significant Events": {
"main": [
[
{
"node": "Log Activity to CRM",
"type": "main",
"index": 0
}
],
[
{
"node": "Skip — Not Significant",
"type": "main",
"index": 0
}
]
]
},
"Log Activity to CRM": {
"main": [
[
{
"node": "Sender Has Email?",
"type": "main",
"index": 0
}
]
]
},
"Sender Has Email?": {
"main": [
[
{
"node": "Find Contact by Email",
"type": "main",
"index": 0
}
],
[]
]
},
"Find Contact by Email": {
"main": [
[
{
"node": "Contact Found?",
"type": "main",
"index": 0
}
]
]
},
"Contact Found?": {
"main": [
[
{
"node": "Touch Contact — Update Timestamp",
"type": "main",
"index": 0
}
],
[
{
"node": "Create Contributor Contact",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "cosmolocal"
},
{
"name": "git"
},
{
"name": "crm"
}
],
"pinData": {}
}

68
n8n-workflows/README.md Normal file
View File

@ -0,0 +1,68 @@
# Cosmolocal n8n Workflows
Import these JSON files into [automate.cosmolocal.world](https://automate.cosmolocal.world) via **Settings > Import Workflow**.
## Setup Requirements
API keys are passed as **Docker environment variables** in `docker-compose.yml` (n8n community edition doesn't support Settings > Variables). The workflows access them via `$env.VARIABLE_NAME`.
| Variable | Description | Where to find |
|----------|-------------|---------------|
| `TWENTY_API_KEY` | Twenty CRM API key | crm.cosmolocal.world > Settings > API Keys |
| `SMTP_USER` | Mailcow SMTP username | e.g. `hello@cosmolocal.world` |
| `SMTP_PASS` | Mailcow SMTP password | Mailcow admin panel at mx.jeffemmett.com |
| `LISTMONK_CREDENTIALS` | Listmonk `user:pass` | Internal Docker service credentials |
Email is sent via **Mailcow SMTP** (`mx.jeffemmett.com:587` STARTTLS). After importing workflows, create an SMTP credential in n8n named "Mailcow SMTP" with the host/user/pass above.
To set keys, create `/opt/websites/cosmolocal-website/.env` on the server and redeploy.
## Workflows
### 01 — Contact Intake (Form to CRM)
**Trigger:** Webhook POST to `/webhook/contact-intake`
**Flow:** Validate input > Create contact in Twenty CRM > Add note with message > Respond
Use this webhook URL in your website contact form:
```
https://automate.cosmolocal.world/webhook/contact-intake
```
POST body:
```json
{
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+1234567890",
"message": "Interested in collaborating"
}
```
### 02 — Lead Nurturing (Welcome Email Sequence)
**Trigger:** Webhook POST to `/webhook/new-contact-created`
**Flow:** Day 0: Welcome email > Day 3: Strategic priorities > Day 10: Ways to participate > Update CRM stage
Chain this from workflow 01 or call it when a new contact is created in the CRM.
### 03 — Newsletter Sync (CRM to Listmonk)
**Trigger:** Daily at 6:00 AM
**Flow:** Fetch CRM contacts > Check if already in Listmonk > Create new subscribers
Syncs all CRM contacts with email addresses to Listmonk list #1. Adjust the list ID if needed.
### 04 — Follow-up Reminders (Stale Contacts)
**Trigger:** Weekly on Monday at 9:00 AM
**Flow:** Fetch contacts > Filter those not updated in 14+ days > Email report to team
Sends an HTML table report to `hello@cosmolocal.world` with stale contacts and a link to the CRM.
### 05 — Webhook Events (Git to CRM)
**Trigger:** Webhook POST to `/webhook/git-events`
**Flow:** Parse Gitea/GitHub event > Log as CRM note > Find or create contributor contact
Add this webhook URL to Gitea/GitHub repos:
```
https://automate.cosmolocal.world/webhook/git-events
```
Supports: push, issues, pull_request, star/watch events. Auto-detects Gitea vs GitHub from headers.