28 KiB
JARVIS Email Integration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give JARVIS read/send access to the kontakt@mbo-tech-it.de mailbox: chat-driven live reading and confirmed sending, plus an n8n-cached "new emails" dashboard widget.
Architecture: imaplib/smtplib (Python stdlib, no new dependency) power two new Claude tools in run_chat_completion (list_recent_emails, send_email). A separate n8n workflow using the built-in Email Trigger (IMAP) node caches new-message metadata into a new email_cache table for a dashboard widget, independent of the chat tools.
Tech Stack: imaplib, smtplib, email (all Python stdlib), FastAPI (existing patterns), Anthropic tool use (existing run_chat_completion loop), n8n REST API, React.
Spec: docs/superpowers/specs/2026-09-13-email-integration-design.md
Global Constraints
- No dedicated git repository — every "Commit" step is replaced by "confirm the file is saved".
- SSH:
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98. Backend:Claude outputs/. Frontend:web/. - Credentials (already verified working 13.09.2026):
EMAIL_IMAP_HOST=mx2f35.netcup.net,EMAIL_IMAP_PORT=143(STARTTLS),EMAIL_SMTP_HOST=mx2f35.netcup.net,EMAIL_SMTP_PORT=465(implicit SSL),EMAIL_USER=kontakt@mbo-tech-it.de,EMAIL_PASSWORD=E%21gq10i(literal string, not URL-encoded). send_emailmust never be called by Claude without the user first confirming recipient/subject/body in the conversation — enforced via system-prompt instruction, same pattern asdelete_calendar_event.- n8n already has a credential "JARVIS Postgres" (id
8b6g9X080UhxUgLv) pointing at thejarvisdatabase, and a fresh credential "JARVIS Email IMAP" (id4rU5KYIv1sKCJkAd) already created with the above IMAP settings (secure: false, since STARTTLS on port 143 is used, not implicit TLS) — reuse both, don't recreate. - The n8n API key from the weather workflow setup is expired/inaccessible; a new one ("JARVIS Email Workflow") was created during spec verification but its value wasn't persisted anywhere durable — generate a fresh one via the n8n UI (Settings → n8n API) when Task 4 needs it, same procedure as the weather workflow.
- n8n's
n8n-nodes-base.emailReadImap(Email Trigger IMAP) node is confirmed present in this n8n version, with apostProcessAction: "nothing"option that must be used so caching doesn't mark real business emails as read.
Task 1: IMAP/SMTP helpers + config + unit tests
Files:
- Modify:
Claude outputs/main.py(imports, config constants,_decode_mime_words,_list_recent_emails_sync/list_recent_emails,_send_email_sync/send_email) - Modify:
Claude outputs/docker-compose.yml(jarvis-api environment) - Test:
Claude outputs/tests/test_email.py
Interfaces:
-
Produces:
async def list_recent_emails(limit: int = 10) -> listreturning[{"from": str, "subject": str, "date": str, "unread": bool}, ...], newest first;async def send_email(to: str, subject: str, body: str) -> dictreturning{"to", "subject", "sent": True}. Consumed by Task 3 (chat tools). -
Step 1: Write the failing tests
Create Claude outputs/tests/test_email.py:
import base64
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import main
def _encoded_subject(text: str) -> bytes:
return b"=?utf-8?B?" + base64.b64encode(text.encode("utf-8")) + b"?="
def _header_bytes(subject_bytes: bytes) -> bytes:
return (
b"From: Sender <sender@example.com>\r\n"
b"Subject: " + subject_bytes + b"\r\n"
b"Date: Fri, 11 Sep 2026 15:47:02 +0000\r\n\r\n"
)
def test_list_recent_emails_decodes_subject_and_marks_unread():
fake_conn = MagicMock()
fake_conn.search.return_value = ("OK", [b"1"])
fake_conn.fetch.return_value = (
"OK",
[(b"1 (FLAGS () BODY[HEADER.FIELDS (FROM SUBJECT DATE)] {123}", _header_bytes(_encoded_subject("Ümlaut Betreff")))],
)
with patch.object(main.imaplib, "IMAP4", return_value=fake_conn):
emails = main._list_recent_emails_sync(limit=1)
assert len(emails) == 1
assert emails[0]["subject"] == "Ümlaut Betreff"
assert emails[0]["from"] == "Sender <sender@example.com>"
assert emails[0]["unread"] is True
fake_conn.login.assert_called_once_with(main.EMAIL_USER, main.EMAIL_PASSWORD)
def test_list_recent_emails_marks_seen_messages_as_read():
fake_conn = MagicMock()
fake_conn.search.return_value = ("OK", [b"1"])
fake_conn.fetch.return_value = (
"OK",
[(b"1 (FLAGS (\\Seen) BODY[HEADER.FIELDS (FROM SUBJECT DATE)] {123}", _header_bytes(b"Plain Subject"))],
)
with patch.object(main.imaplib, "IMAP4", return_value=fake_conn):
emails = main._list_recent_emails_sync(limit=1)
assert emails[0]["unread"] is False
assert emails[0]["subject"] == "Plain Subject"
def test_send_email_calls_smtp_with_composed_message():
fake_smtp = MagicMock()
with patch.object(main.smtplib, "SMTP_SSL", return_value=fake_smtp):
result = main._send_email_sync("empfaenger@example.com", "Betreff", "Text")
fake_smtp.login.assert_called_once_with(main.EMAIL_USER, main.EMAIL_PASSWORD)
fake_smtp.send_message.assert_called_once()
sent_msg = fake_smtp.send_message.call_args[0][0]
assert sent_msg["To"] == "empfaenger@example.com"
assert sent_msg["Subject"] == "Betreff"
assert result == {"to": "empfaenger@example.com", "subject": "Betreff", "sent": True}
- Step 2: Run tests to verify they fail
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /tmp/jarvis-test/tests"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" "$OUT/requirements-dev.txt" "$OUT/pytest.ini" jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp $SSHOPTS "$OUT/tests/test_email.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_email.py -v'"
Expected: FAIL — _list_recent_emails_sync/_send_email_sync don't exist yet.
- Step 3: Add imports and config constants
In Claude outputs/main.py, add near the other imports (after import caldav):
import imaplib
import smtplib
import ssl
from email import message_from_bytes
from email.header import decode_header
from email.message import EmailMessage
Add near the other # ============ CONFIG ============ constants:
EMAIL_IMAP_HOST = os.getenv("EMAIL_IMAP_HOST")
EMAIL_IMAP_PORT = int(os.getenv("EMAIL_IMAP_PORT", "143"))
EMAIL_SMTP_HOST = os.getenv("EMAIL_SMTP_HOST")
EMAIL_SMTP_PORT = int(os.getenv("EMAIL_SMTP_PORT", "465"))
EMAIL_USER = os.getenv("EMAIL_USER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")
- Step 4: Add the email helpers
Add after the calendar helpers (async def delete_event(...), end of that block):
def _decode_mime_words(value: str) -> str:
parts = decode_header(value)
decoded = []
for text, charset in parts:
if isinstance(text, bytes):
decoded.append(text.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(text)
return "".join(decoded)
def _list_recent_emails_sync(limit: int = 10) -> list:
conn = imaplib.IMAP4(EMAIL_IMAP_HOST, EMAIL_IMAP_PORT)
conn.starttls(ssl.create_default_context())
conn.login(EMAIL_USER, EMAIL_PASSWORD)
conn.select("INBOX", readonly=True)
_, data = conn.search(None, "ALL")
ids = data[0].split()[-limit:]
emails = []
for msg_id in reversed(ids):
_, msg_data = conn.fetch(msg_id, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] FLAGS)")
flags_line = msg_data[0][0].decode("utf-8", errors="replace")
msg = message_from_bytes(msg_data[0][1])
emails.append(
{
"from": _decode_mime_words(msg.get("From", "")),
"subject": _decode_mime_words(msg.get("Subject", "")),
"date": msg.get("Date", ""),
"unread": "\\Seen" not in flags_line,
}
)
conn.logout()
return emails
async def list_recent_emails(limit: int = 10) -> list:
return await asyncio.to_thread(_list_recent_emails_sync, limit)
def _send_email_sync(to: str, subject: str, body: str) -> dict:
msg = EmailMessage()
msg["From"] = EMAIL_USER
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
conn = smtplib.SMTP_SSL(EMAIL_SMTP_HOST, EMAIL_SMTP_PORT, context=ssl.create_default_context())
conn.login(EMAIL_USER, EMAIL_PASSWORD)
conn.send_message(msg)
conn.quit()
return {"to": to, "subject": subject, "sent": True}
async def send_email(to: str, subject: str, body: str) -> dict:
return await asyncio.to_thread(_send_email_sync, to, subject, body)
- Step 5: Run tests to verify they pass
Re-run the Step 2 command. Expected: 3 passed.
- Step 6: Wire the email env vars into the deployed container
In Claude outputs/docker-compose.yml, under jarvis-api: environment:, add after NEXTCLOUD_APP_PASSWORD:
- EMAIL_IMAP_HOST=mx2f35.netcup.net
- EMAIL_IMAP_PORT=143
- EMAIL_SMTP_HOST=mx2f35.netcup.net
- EMAIL_SMTP_PORT=465
- EMAIL_USER=kontakt@mbo-tech-it.de
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
Add the password to the deployed .env:
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "grep -q EMAIL_PASSWORD /home/jarvis-core/jarvis/.env || echo 'EMAIL_PASSWORD=E%21gq10i' >> /home/jarvis-core/jarvis/.env"
- Step 7: Confirm files saved (no git repo for this project — see Global Constraints)
Task 2: email_cache table + GET /api/v1/emails endpoint
Files:
- Create:
Claude outputs/migrations/005_email_cache.sql - Modify:
Claude outputs/main.py(addget_latest_emailsDB helper + endpoint)
Interfaces:
-
Produces:
GET /api/v1/emails?limit=10returning{"emails": [...]}, each{"sender", "subject", "received_at"}. Consumed by Task 5's dashboard widget. Populated by Task 4's n8n workflow. -
Step 1: Create and apply the migration
Create Claude outputs/migrations/005_email_cache.sql:
CREATE TABLE email_cache (
id SERIAL PRIMARY KEY,
sender VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
received_at TIMESTAMP NOT NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_email_cache_received_at ON email_cache(received_at);
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
scp $SSHOPTS "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs\migrations\005_email_cache.sql" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/migrations/005_email_cache.sql
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker exec -i jarvis-postgres psql -U jarvis -d jarvis < /home/jarvis-core/jarvis/migrations/005_email_cache.sql"
Expected output: CREATE TABLE, CREATE INDEX.
- Step 2: Add the DB helper and endpoint
In Claude outputs/main.py, add near the other DB helpers (after get_latest_weather):
async def get_latest_emails(limit: int = 10):
return await db_query(
"SELECT sender, subject, received_at FROM email_cache ORDER BY received_at DESC LIMIT %s",
(limit,),
fetch="all",
)
Add the endpoint after get_calendar_events:
@app.get("/api/v1/emails", dependencies=[Depends(require_admin_key)])
async def get_cached_emails(limit: int = 10):
"""Recently cached emails, populated by the n8n 'Neue E-Mails Cache' workflow"""
if not pg_pool:
raise HTTPException(status_code=503, detail="Database is not configured")
rows = await get_latest_emails(limit)
return {
"emails": [
{
"sender": row["sender"],
"subject": row["subject"],
"received_at": row["received_at"].isoformat(),
}
for row in rows
]
}
- Step 3: Deploy and verify against the live API
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
scp $SSHOPTS "$OUT/docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api"
Expected: log ends with Application startup complete..
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -H 'X-Admin-Key: $ADMIN_KEY' http://localhost:8000/api/v1/emails; echo"
Expected: {"emails":[]} (table is empty until Task 4's workflow runs).
- Step 4: Confirm files saved (no git repo for this project — see Global Constraints)
Task 3: Chat tool use (read + confirmed send)
Files:
- Modify:
Claude outputs/main.py(tool definitions,execute_tool, system-prompt instructions) - Test:
Claude outputs/tests/test_chat_tools.py(extend)
Interfaces:
-
Consumes:
list_recent_emails,send_email(Task 1). -
Produces: two new entries in
CALENDAR_TOOLS(kept in that list/constant name to avoid a second near-identical tools array — rename is out of scope) and matching branches inexecute_tool. -
Step 1: Add the tool definitions
In Claude outputs/main.py, add two entries to the CALENDAR_TOOLS list (after delete_calendar_event's closing },):
{
"name": "list_recent_emails",
"description": "List the most recent emails in the kontakt@mbo-tech-it.de inbox.",
"input_schema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "How many recent emails to list, e.g. 10"}
},
"required": ["limit"],
},
},
{
"name": "send_email",
"description": (
"Send an email from kontakt@mbo-tech-it.de. Only call this after "
"the user has explicitly confirmed recipient, subject and body "
"in the conversation."
),
"input_schema": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient email address"},
"subject": {"type": "string", "description": "Email subject"},
"body": {"type": "string", "description": "Plain text email body"},
},
"required": ["to", "subject", "body"],
},
},
- Step 2: Extend the system-prompt instructions
In Claude outputs/main.py, change the CALENDAR_ASSISTANT_INSTRUCTIONS string (append to the existing text, keep everything before it unchanged):
CALENDAR_ASSISTANT_INSTRUCTIONS = (
"Du hast Zugriff auf den Kalender 'FFW-Onza-Alle' ueber die Tools "
"list_calendar_events, create_calendar_event, update_calendar_event und "
"delete_calendar_event. create_calendar_event und update_calendar_event "
"kannst du direkt aufrufen, sobald du die noetigen Angaben hast - keine "
"Rueckfrage noetig. Bevor du delete_calendar_event aufrufst, frage den "
"Nutzer aber immer explizit im Klartext nach Bestaetigung und rufe das "
"Tool erst auf, nachdem der Nutzer im naechsten Chat-Beitrag zugestimmt "
"hat. Fuer update_calendar_event und delete_calendar_event brauchst du "
"die uid des Termins - ruf dafuer zuerst list_calendar_events auf, falls "
"du sie noch nicht aus dem Gespraech kennst.\n\n"
"Du hast ausserdem Zugriff auf das Postfach kontakt@mbo-tech-it.de ueber "
"die Tools list_recent_emails und send_email. list_recent_emails kannst "
"du direkt aufrufen. Bevor du send_email aufrufst, zeige dem Nutzer "
"Empfaenger, Betreff und Text zur Kontrolle und rufe das Tool erst auf, "
"nachdem der Nutzer im naechsten Chat-Beitrag zugestimmt hat - "
"kontakt@mbo-tech-it.de ist die offizielle Firmenadresse, eine "
"versendete Mail laesst sich nicht zurueckholen."
)
- Step 3: Wire the dispatcher
In Claude outputs/main.py, add to execute_tool (after the delete_calendar_event branch, before raise ValueError):
if name == "list_recent_emails":
emails = await list_recent_emails(tool_input.get("limit", 10))
return json.dumps(emails)
if name == "send_email":
result = await send_email(tool_input["to"], tool_input["subject"], tool_input["body"])
return json.dumps(result)
- Step 4: Extend the chat-tools regression test
In Claude outputs/tests/test_chat_tools.py, add at the end of the file:
@pytest.mark.asyncio
async def test_run_chat_completion_lists_recent_emails():
first = MagicMock()
first.stop_reason = "tool_use"
first.content = [_tool_use_block("list_recent_emails", {"limit": 5}, "tool_1")]
first.usage = _usage(15, 6)
second = MagicMock()
second.stop_reason = "end_turn"
second.content = [_text_block("Du hast 5 neue Mails.")]
second.usage = _usage(20, 8)
main.claude_client = MagicMock()
main.claude_client.messages.create.side_effect = [first, second]
with patch.object(main, "list_recent_emails", new=AsyncMock(return_value=[])):
text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Was ist neu im Postfach?"}]
)
assert text == "Du hast 5 neue Mails."
assert output_tokens == 6 + 8
assert total_tokens == 15 + 6 + 20 + 8
- Step 5: Run all backend tests
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/main.py
scp $SSHOPTS "$OUT/tests/test_chat_tools.py" "$OUT/tests/test_email.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/ -v'"
Expected: all tests pass (should be 21 total: 17 previous + 3 email + 1 new chat-tools).
- Step 6: Deploy and verify against the live API
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose restart jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api"
Expected: log ends with Application startup complete..
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Was ist neu im Postfach kontakt@mbo-tech-it.de? Zeig die letzten 5 Mails.\"}'; echo"
Expected: a natural-language response listing real subjects/senders from the live inbox (proof the tool round-trip actually hit IMAP).
Then test the confirmation gate:
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Schick eine Testmail an kontakt@mbo-tech-it.de mit Betreff JARVIS Test und Text Hallo aus JARVIS.\"}'; echo"
Expected: JARVIS asks for confirmation, does NOT send yet (no is_error and no immediate "gesendet" claim). Confirm in a follow-up (conversation_id from the response) with {"conversation_id": <id>, "message": "Ja, senden."} and expect a real send confirmation - then check the inbox arrived (e.g. re-run list_recent_emails via chat, or check via the Task 1 IMAP test pattern).
- Step 7: Confirm files saved (no git repo for this project — see Global Constraints)
Task 4: n8n "Neue E-Mails Cache" workflow
Files: none (n8n workflow lives in n8n's own database, not in this repo)
Interfaces:
-
Consumes: n8n credentials "JARVIS Email IMAP" (id
4rU5KYIv1sKCJkAd) and "JARVIS Postgres" (id8b6g9X080UhxUgLv), both already created. -
Produces: rows in
email_cache(Task 2's table), consumed by Task 5's widget. -
Step 1: Generate a fresh n8n API key
Via browser: log in at https://n8n.jarvis.mbo-tech-it.de (owner account jonny@mbo-tech-it.de), Settings → n8n API → Create API key, label "JARVIS Email Workflow Runtime", scope "All". Copy the value into a page-local JS variable immediately (it's only shown once) — same procedure as the weather workflow setup: paste into a visible input (e.g. the API-key-list search box) and read .value via javascript_exec, since the creation dialog's own field is CSS-truncated.
- Step 2: Create the workflow via the n8n REST API
Using fetch in the browser page context with X-N8N-API-KEY set to the key from Step 1 (POST to /api/v1/workflows):
{
"name": "Neue E-Mails Cache",
"nodes": [
{
"id": "trigger1",
"name": "Email Trigger IMAP",
"type": "n8n-nodes-base.emailReadImap",
"typeVersion": 2,
"position": [240, 300],
"parameters": {
"mailbox": "INBOX",
"postProcessAction": "nothing",
"format": "simple",
"options": {}
},
"credentials": {
"imap": { "id": "4rU5KYIv1sKCJkAd", "name": "JARVIS Email IMAP" }
}
},
{
"id": "pg1",
"name": "Insert Email",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [460, 300],
"parameters": {
"operation": "insert",
"schema": { "__rl": true, "value": "public", "mode": "list" },
"table": { "__rl": true, "value": "email_cache", "mode": "list" },
"columns": {
"mappingMode": "defineBelow",
"value": {
"sender": "={{ $json.from }}",
"subject": "={{ $json.subject }}",
"received_at": "={{ $json.date }}"
},
"matchingColumns": [],
"schema": []
}
},
"credentials": {
"postgres": { "id": "8b6g9X080UhxUgLv", "name": "JARVIS Postgres" }
}
}
],
"connections": {
"Email Trigger IMAP": { "main": [[{ "node": "Insert Email", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" }
}
Note: the exact field name for the sender on the IMAP trigger's "simple" format output is unconfirmed (could be a plain string or an object like {text, value}} depending on the mail parser) — this is discovered and corrected in Step 4 below using a real test email, the same way the weather workflow's node parameters were verified against the live API before finalizing.
- Step 3: Activate the workflow
POST /api/v1/workflows/<id>/activate
with the same API key.
- Step 4: Send a real test email and verify the cache row
Use the already-deployed and tested send_email (Task 3) to send a message to the inbox itself, so the new trigger has something to catch:
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Schick eine Testmail an kontakt@mbo-tech-it.de mit Betreff JARVIS n8n Cache Test und Text Test.\"}'; echo"
Confirm the send in a follow-up chat message (see Task 3 Step 6's confirmation pattern). Wait ~30-60s for the IMAP trigger to poll, then check:
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker exec jarvis-postgres psql -U jarvis -d jarvis -c 'SELECT sender, subject, received_at FROM email_cache ORDER BY id DESC LIMIT 3;'"
Expected: a row with subject "JARVIS n8n Cache Test". If the sender/subject/received_at columns are empty or malformed, inspect the actual execution data in the n8n UI (Executions tab on the workflow) to see the real output shape from the "Email Trigger IMAP" node, and adjust the Postgres node's columns.value expressions accordingly (e.g. $json.from.text instead of $json.from) via a PATCH /api/v1/workflows/<id> call, then resend a test email to reverify.
- Step 5: Confirm the workflow is saved (lives in n8n's own DB, not this repo — no file to save)
Task 5: "Neue E-Mails" dashboard widget
Files:
- Create:
web/src/components/EmailWidget.tsx - Modify:
web/src/components/Dashboard.tsx(render it)
Interfaces:
-
Consumes:
apiFetch(existing),GET /api/v1/emails(Task 2). -
Produces:
EmailWidgetdefault export, no props, rendered insideDashboard. -
Step 1: Create the widget
Create web/src/components/EmailWidget.tsx:
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type CachedEmail = { sender: string; subject: string; received_at: string };
export default function EmailWidget() {
const [emails, setEmails] = useState<CachedEmail[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await apiFetch("/api/v1/emails?limit=5");
if (!response.ok) {
setError("Postfach-Cache nicht verfuegbar");
return;
}
const data = await response.json();
setEmails(data.emails);
} catch {
setError("Postfach-Cache nicht verfuegbar");
}
}
load();
}, []);
return (
<div className="email-widget">
<h3>Neue E-Mails</h3>
{error && <p className="error">{error}</p>}
{emails && emails.length === 0 && <p>Noch keine gecachten E-Mails.</p>}
{emails && emails.length > 0 && (
<ul>
{emails.map((e, i) => (
<li key={i}>
{`${new Date(e.received_at).toLocaleDateString("de-DE")}: ${e.sender} \u2013 ${e.subject}`}
</li>
))}
</ul>
)}
</div>
);
}
- Step 2: Render it in the Dashboard
In web/src/components/Dashboard.tsx, add the import next to CalendarWidget's:
import EmailWidget from "./EmailWidget";
Add <EmailWidget /> right after <CalendarWidget />.
- Step 3: Verify the build works
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm run build
Expected: exits 0.
- Step 4: Deploy
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS"
scp $SSHOPTS -r "$OUT\web\src" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose build jarvis-web && docker compose up -d jarvis-web"
- Step 5: End-to-end browser verification
Using the claude-in-chrome browser tools: log in at https://jarvis.mbo-tech-it.de (append a cache-busting query param like ?nocache=1 on first load after deploy if the page looks stale, per the known nginx/browser-cache behavior documented in JARVIS_HANDOFF.md), open Dashboard, confirm "Neue E-Mails" lists the test email(s) from Task 4.
- Step 6: Confirm files saved (no git repo for this project — see Global Constraints)