Knotter Architecture
knotter is a personal CRM / friendship-tracking tool designed for terminal-first workflows. It is built as an offline-first Rust application with a CLI and a TUI, backed by a portable SQLite database, with standards-based import/export.
This document describes:
- the workspace layout and module boundaries
- the core domain model and invariants
- storage (SQLite) schema and migrations
- error handling conventions
- filtering/query semantics
- import/export design
- reminders/notifications architecture
- testing and operational expectations
1. Design goals
Primary goals
- System-agnostic: knotter should run on most Linux/Unix machines with minimal assumptions.
- Offline-first: local database is the source of truth; the app remains useful with no network.
- Terminal-first UX: fully usable via CLI; TUI provides fast browsing/editing.
- Personal CRM features:
- contacts (people)
- interactions/notes (relationship history)
- scheduling next touchpoint (future intent)
- Tags + filtering: quickly list contacts by category and due state.
- Interchange:
- export/import contacts via vCard (.vcf)
- export touchpoints via iCalendar (.ics)
- Optional syncing with macOS apps (post-MVP): via standards-based CardDAV/CalDAV, not by poking at Apple local databases.
Non-goals (MVP)
- Full bidirectional sync with iCloud “local” stores.
- Running a mandatory background daemon.
- AI features or “relationship scoring” heuristics.
- Complex recurring schedules (beyond basic cadence + next date).
2. High-level architecture
knotter is split into layers to keep UI and persistence separate from business logic.
Layer overview
- knotter-core
- Pure domain types and business rules.
- No SQLite, no terminal, no file I/O.
- knotter-config
- Loads config from XDG paths or
--config. - Validates values and enforces strict permissions (where supported).
- Loads config from XDG paths or
- knotter-store
- SQLite persistence, migrations, and repositories.
- Converts between DB rows and core domain types.
- knotter-sync
- Import/export adapters:
- vCard (.vcf) for contacts
- iCalendar (.ics) for touchpoints
- Telegram 1:1 sync (snippets only)
- Future: CardDAV/CalDAV sync (optional).
- Import/export adapters:
- knotter-cli
- CLI frontend (commands, argument parsing).
- Calls core/store/sync; prints output; exit codes.
- knotter-tui
- TUI frontend using Ratatui + Crossterm.
- Maintains application state and renders views.
- knotter (bin)
- Small binary crate that wires everything together.
- Can expose both CLI and
knotter tui.
Dependency direction (must not be violated)
- knotter-core: depends on (almost) nothing; ideally only
uuid,serde(optional), and a time crate. - knotter-config: depends on core for validation helpers.
- knotter-store: depends on core + SQLite libs.
- knotter-sync: depends on core + parsing/generation libs; may depend on store when import wants to upsert.
- knotter-cli / knotter-tui: depend on core/config/store/sync; never the other way around.
3. Workspace layout
Recommended structure:
Cargo.toml(workspace root)crates/knotter-core/src/lib.rsdomain/(contact.rs, interaction.rs, tag.rs)rules/(due.rs, cadence.rs)filter/(parser.rs, ast.rs)error.rs
knotter-config/src/lib.rs(XDG config lookup + TOML parsing)
knotter-store/src/lib.rsdb.rs(connection/open, pragmas)migrate.rsrepo/(contacts.rs, tags.rs, interactions.rs)error.rs
migrations/001_init.sql002_...sql
knotter-sync/src/lib.rsvcf/(import.rs, export.rs, mapping.rs)ics/(export.rs, mapping.rs, uid.rs)error.rs
knotter-cli/src/main.rs(or lib+bin style)commands/(add_contact.rs, list.rs, remind.rs, import.rs, export.rs, ...)output/(human.rs, json.rs)
knotter-tui/src/main.rs(or lib+bin style)app.rs(App state)ui/(render.rs, widgets.rs)modes/(list.rs, detail.rs, modals/)events.rs(input mapping)error.rs
knotter/(optional “umbrella” bin)src/main.rs
The key is: core is reusable and UI crates are replaceable.
4. Core domain model (knotter-core)
4.1 Identity and time
IDs
Use UUIDs for portability and for stable export identifiers. Define newtypes for IDs to prevent mixing them up:
ContactId(Uuid)InteractionId(Uuid)TagId(Uuid)ContactDateId(Uuid)
Time representation
Use UTC timestamps in storage and business logic. Recommended:
- store timestamps as
i64unix seconds (UTC) in SQLite - convert to/from a Rust datetime type at the edges
Define in core:
Timestampwrapper or use a time crate type- always treat DB timestamps as UTC
- “today/due soon” computations use the local machine timezone (MVP) unless contact timezone is explicitly supported later
4.2 Entities
Contact
A contact represents a person you want to keep in touch with.
Core fields:
id: ContactIddisplay_name: String(required, non-empty)email: Option<String>(primary email; additional emails live incontact_emails)phone: Option<String>handle: Option<String>(free text: Discord/IG/etc.)timezone: Option<String>(IANA TZ string; optional MVP)created_at: i64(unix seconds UTC)updated_at: i64(unix seconds UTC)next_touchpoint_at: Option<i64>(unix seconds UTC)cadence_days: Option<i32>(e.g. 7, 14, 30)archived_at: Option<i64>(optional; included in schema but UI support can be post-MVP)
Invariants:
display_name.trim()must not be empty.cadence_days, if set, must be > 0 and within a reasonable range (e.g. <= 3650).next_touchpoint_at, if set, should be a valid timestamp (>= 0 recommended).- Contact emails are normalized lowercase; exactly one may be marked primary.
contact_emails.sourcetracks provenance (e.g., cli/tui/vcf or email account name).
Interaction
An interaction is a timestamped note/history entry for a contact.
Core fields:
id: InteractionIdcontact_id: ContactIdoccurred_at: i64(when the interaction happened; default “now”)created_at: i64(when it was logged; default “now”)kind: InteractionKindnote: String(can be empty, but usually non-empty is better)follow_up_at: Option<i64>(optional per-interaction follow-up date)
InteractionKind:
CallTextHangoutEmailTelegramOther(String)(must be normalized/trimmed)
Invariants:
Other(s)should be stored as trimmed; reject empty.occurred_atshould not be wildly in the future (soft validation; warning not hard error).
Tag
Tags are categories, like “designer”, “family”, “school”, “soccer”, etc.
Core fields:
id: TagIdname: String(normalized)
Normalization rules (must be identical everywhere):
- trim
- lowercase
- replace spaces with
- - collapse repeated
- - reject empty after normalization
ContactDate
Contact dates capture birthdays and other annual milestones.
Core fields:
id: ContactDateIdcontact_id: ContactIdkind: ContactDateKind(birthday,name_day,custom)label: Option<String>(required forcustom)month: u8(1-12)day: u8(1-31, validated against month)year: Option<i32>(optional, used for birthdays/notes)created_at: i64updated_at: i64source: Option<String>(cli/tui/vcf/etc)
Invariants:
customdates require a non-empty label.- Month/day must be a valid calendar day;
Feb 29is allowed without a year. - Date occurrences are evaluated in the local machine timezone (MVP).
- On non-leap years,
Feb 29occurrences are surfaced onFeb 28.
4.3 Business rules
Due state
Given now (UTC) and local timezone rules for “today”:
- Overdue:
next_touchpoint_at < now - Due today: same local date as now
- Due soon: within N days (configurable, e.g. 7)
- Scheduled: anything later
- Unscheduled:
next_touchpoint_at == None
This logic lives in core so both CLI and TUI behave identically.
Cadence helper
If a contact has cadence_days:
- after a “touch” action (or interaction with rescheduling enabled), set
next_touchpoint_at = max(now, occurred_at) + cadence_days(never in the past)
Optional rule (MVP decision):
- If
next_touchpoint_atalready exists and is later thannow, only reschedule if user explicitly requests (e.g., via CLI flags orinteractions.auto_reschedule).
Scheduling guard:
- User-provided
next_touchpoint_atinputs must benowor later. - Date-only inputs are interpreted as end-of-day local time (so "today" remains scheduled).
5. Filter/query language (knotter-core::filter)
Filtering is used by both CLI and TUI. knotter defines a minimal filter string that compiles into a query AST.
5.1 Supported syntax (MVP)
- Plain text token:
- matches
display_name,email,phone,handle - optionally matches recent interaction notes (post-MVP, because it’s heavier)
- matches
- Tag tokens:
#designer(require tag “designer”)
- Due tokens:
due:overduedue:todaydue:soondue:any(any scheduled, including overdue/today/soon/later)due:none(unscheduled)
- Archived tokens:
archived:true(only archived contacts)archived:false(only active contacts)
Combining:
- Default combination is AND across tokens.
- Multiple tags are AND by default:
#designer #foundermeans must have both tags.
- (Optional later) OR groups:
#designer,#engineermeans either tag
Default UI behavior:
- CLI/TUI list views exclude archived contacts unless explicitly included via flags or
archived:true.
5.2 AST types
FilterExprText(String)Tag(String)(normalized)Due(DueSelector)Archived(ArchivedSelector)And(Vec<FilterExpr>)- (Later)
Or(Vec<FilterExpr>)
5.3 Parser behavior
- Tokenize on whitespace.
- Tokens starting with
#become Tag filters. - Tokens starting with
due:become Due filters. - Tokens starting with
archived:become Archived filters. - Everything else becomes Text filters.
- Invalid tokens:
- unknown
due:value -> return parse error - unknown
archived:value -> return parse error - empty tag after
#-> parse error
- unknown
The parser returns:
Result<ContactFilter, FilterParseError>
6. Storage architecture (knotter-store)
knotter-store is the only layer that touches SQLite. It provides repositories that operate on core types and filter ASTs.
6.1 SQLite connection + pragmas
Open DB at XDG data path:
$XDG_DATA_HOME/knotter/knotter.sqlite3- fallback to
~/.local/share/knotter/knotter.sqlite3
Recommended pragmas (document + test):
PRAGMA foreign_keys = ON;PRAGMA journal_mode = WAL;(improves concurrency; safe default for a local app)PRAGMA synchronous = NORMAL;(balance performance/safety)PRAGMA busy_timeout = 2000;(avoid “database is locked” on short contention)
6.2 Migrations
knotter uses numbered SQL migrations stored in crates/knotter-store/migrations/.
Migration requirements:
- A schema version table:
knotter_schema(version INTEGER NOT NULL)
- On startup:
- open DB
- apply migrations in order inside a transaction
- update schema version
- Migrations must be idempotent in the sense that:
- they only run once each
- schema version ensures ordering
6.3 Schema (MVP)
The authoritative SQL schema lives in Database Schema. Keep this document aligned with it.
Summary of MVP tables/indexes:
knotter_schema(version)for migration tracking.contactswitharchived_atincluded for future archiving (unused in MVP UI).tags(normalized),contact_tagsjoin table.interactionswithkindstored as a normalized string.- Indexes on
contacts.display_name,contacts.next_touchpoint_at,contacts.archived_at,tags.name,contact_tagsforeign keys, andinteractions(contact_id, occurred_at DESC).
Notes:
- IDs are stored as TEXT UUIDs.
- Timestamps are INTEGER unix seconds UTC.
6.4 Repository boundaries
Expose repositories as traits in knotter-store (or as concrete structs with a stable API). Avoid leaking SQL details to callers.
ContactsRepository
create_contact(...) -> Contactupdate_contact(...) -> Contactget_contact(id) -> Option<Contact>delete_contact(id) -> ()(hard delete MVP)archive_contact(id) -> Contactunarchive_contact(id) -> Contactlist_contacts(query: ContactQuery) -> Vec<ContactListItem>
ContactListItem is a lightweight projection for list views:
- id
- display_name
- next_touchpoint_at
- due_state (computed in core, not stored)
- tags (either eager-loaded or loaded separately; choose based on performance)
TagsRepository
upsert_tag(name) -> Tag(normalize before upsert)list_tags_with_counts() -> Vec<(Tag, count)>set_contact_tags(contact_id, tags: Vec<TagName>)(replace set)add_tag(contact_id, tag)remove_tag(contact_id, tag)list_tags_for_contact(contact_id) -> Vec<Tag>list_names_for_contacts(contact_ids: &[ContactId]) -> Map<ContactId, Vec<String>>(bulk tag lookup for list views; uses per-call temp table to avoid collisions)
InteractionsRepository
add_interaction(...) -> Interactionlist_interactions(contact_id, limit, offset) -> Vec<Interaction>delete_interaction(interaction_id)(optional MVP)touch(contact_id, occurred_at, kind, note, reschedule: bool)(convenience)
6.5 Query compilation strategy
knotter-core provides a parsed filter AST. knotter-store translates AST -> SQL WHERE + bind parameters.
Rules:
-
Always use bound parameters, never string interpolation (avoid SQL injection even in local tools).
-
For tag filters, use EXISTS subqueries:
- require all tags -> multiple EXISTS clauses
-
For due filters:
- compare
next_touchpoint_atto now and to “today boundaries” computed in Rust
- compare
Implementation note:
-
Because “today” boundaries depend on local timezone, compute:
- start_of_today_local -> convert to UTC timestamp
- start_of_tomorrow_local -> convert to UTC timestamp Then query ranges in UTC.
7. Import/export architecture (knotter-sync)
knotter-sync contains adapters that map between external formats and core types.
7.1 vCard (.vcf)
Import strategy (MVP)
-
Parse each card into an intermediate
VCardContactstructure. -
Map into knotter
ContactCreate+ tags:- FN -> display_name
- EMAIL (all) -> contact_emails (first becomes primary)
- first TEL -> phone
- CATEGORIES -> tags (normalized)
-
Deduplication:
- If email matches an existing contact, update that contact.
- When phone+name matching is enabled, normalize the phone and match by display name + phone.
- Ambiguous matches create merge candidates for manual resolution.
- Archived-only matches are skipped with a warning.
Manual merge candidates are created when imports/sync encounter ambiguous matches
(e.g., multiple name matches or duplicate emails). Candidates are resolved via
knotter merge or the TUI merge list.
Applying a merge marks the chosen candidate as merged and dismisses any other
open candidates that referenced the removed contact.
Some candidate reasons are marked auto-merge safe (currently duplicate-email and
vcf-ambiguous-phone-name), which enables bulk apply workflows.
Import should return a report:
- created_count
- updated_count
- skipped_count
- warnings (invalid tags, missing FN, etc.)
Contact sources (macOS + CardDAV)
Additional sources should convert their data into vCard payloads and reuse the existing vCard import pipeline. This keeps dedupe logic and mapping consistent.
- macOS Contacts: fetch vCards via the Contacts app (AppleScript / Contacts framework); import enables phone+name matching by default to reduce duplicates when emails are missing.
- CardDAV providers (Gmail, iCloud, etc.): fetch addressbook vCards via CardDAV REPORT.
Export strategy (MVP)
-
For each contact:
- emit FN
- emit EMAIL/TEL if present
- emit CATEGORIES from tags
-
Optional: include custom
X-fields for knotter metadata:X-KNOTTER-NEXT-TOUCHPOINT: <unix or iso datetime>X-KNOTTER-CADENCE-DAYS: <int>BDAY: <YYYY-MM-DD, YYYYMMDD, --MMDD, or --MM-DD>(birthday when available)X-KNOTTER-DATE: <kind>|<date>|<label>(name-day/custom dates and extra/labeled birthdays)
Round-trip expectations must be documented:
- Other apps may ignore X- fields (fine).
- knotter should preserve its own X- fields when re-importing its own export.
7.3 Email account sync (IMAP, post-MVP)
Email sync ingests headers from configured IMAP inboxes and maps them into contact emails + interaction history:
- If an email address matches an existing contact, attach it (and record an email touch).
- If it matches none, create a new contact.
- If it matches a unique display name, merge by adding the email to that contact.
- If it matches multiple display names, stage an archived contact and create merge candidates.
- Duplicate-email conflicts create merge candidates for manual resolution.
- Each new message creates an
InteractionKind::Emailentry. - Sync is incremental using
email_sync_state(account/mailbox, UIDVALIDITY, last UID).
7.4 Telegram 1:1 sync (snippets-only)
Telegram sync ingests 1:1 user chats (no groups) and stores snippets only:
- Each Telegram user maps to a contact via
contact_telegram_accounts(telegram user id, username, phone, names). - If a telegram user id is already linked, update metadata + record interactions.
- If no link exists:
- match by username when available (including contact handles)
- otherwise (and only when enabled) match by display name
- ambiguous matches create merge candidates; a staged archived contact holds the telegram id
--messages-onlyskips staging and only attaches to unambiguous matches
- Each imported message inserts:
telegram_messagesrow for dedupeInteractionKind::Telegramwith a snippet note
- Sync state is tracked per account + peer via
telegram_sync_state(last_message_id). - First-time authentication requires a login code; non-interactive runs can provide
KNOTTER_TELEGRAM_CODEand (for 2FA)KNOTTER_TELEGRAM_PASSWORD.
7.2 iCalendar (.ics) for touchpoints
knotter uses calendar events as an export mechanism for scheduled touchpoints.
Event generation rules
-
One event per contact that has
next_touchpoint_at. -
Summary:
Reach out to {display_name}
-
DTSTART:
next_touchpoint_atas UTC or local-floating time (choose one; UTC recommended for simplicity)
-
Description:
- tags and/or a short “last interaction” snippet (optional)
-
UID:
-
stable and deterministic so repeated exports update the same event:
knotter-{contact_uuid}@local(or similar)
-
Export options:
- export window (e.g. next 60 days)
- export due-only
7.3 JSON snapshot export
knotter also supports a JSON snapshot export for portability and backups.
Snapshot rules:
- include metadata (export timestamp, app version, schema version, format version)
- include all contacts with tags and full interaction history
- interactions are ordered with most recent first
- archived contacts are included by default with an
--exclude-archivedescape hatch
Import of ICS back into knotter is post-MVP.
8. Reminders and notifications
knotter supports reminders without requiring a daemon.
8.1 Reminder computation
-
knotter remindqueries scheduled contacts and groups by:- overdue
- due today
- due soon (configurable days)
- dates today (birthdays/custom dates that occur on the local date)
-
“Due soon” threshold is config-driven (default 7).
8.2 Notification interface
Define a small trait in a shared place (either core or a small knotter-notify module, but keep core free of OS calls):
Notifier::send(title: &str, body: &str) -> Result<()>
Backends:
- Stdout (always available)
- Desktop notification (optional feature flag)
- Email (optional feature flag, SMTP via config/env)
Behavior:
- If desktop notification fails, fall back to stdout (do not crash).
- CLI decides whether to notify (
--notify) or just print.
8.3 System scheduling
knotter intentionally relies on external schedulers:
- cron
- systemd user timers
- (optional) macOS launchd for reminders on macOS
knotter provides stable, script-friendly outputs:
--jsonmode for automation- exit codes that reflect success/failure
9. CLI architecture (knotter-cli)
knotter-cli is a thin coordinator.
Responsibilities:
- parse args into command structs
- open DB + run migrations
- call repositories and core functions
- format output (human or JSON)
- set exit codes
Conventions:
- Human output is readable and stable enough for casual scripting.
- JSON output is versioned or at least documented to avoid breaking users.
Error handling:
- Validate obvious bad inputs at the CLI layer (e.g., invalid date format).
- Let store/core return typed errors; convert to friendly messages.
10. TUI architecture (knotter-tui)
The TUI is a state machine with explicit modes.
10.1 Application state model
App holds:
mode: Modefilter_input: Stringparsed_filter: Option<ContactFilter>list: Vec<ContactListItem>selected_index: usizedetail: Option<ContactDetail>(selected contact, tags, recent interactions)status_message: Option<String>error_message: Option<String>- config values (soon window, etc.)
10.2 Modes
ListDetail(contact_id)FilterEditingModalAddContactModalEditContact(contact_id)ModalAddNote(contact_id)ModalEditTags(contact_id)ModalSchedule(contact_id)
Each mode defines:
- allowed keybindings
- how input is interpreted
- which components are rendered
- what side effects occur (DB writes)
10.3 Event loop + side effects
Key rules:
-
Never block the render loop for “long” operations.
-
Use a simple command queue pattern:
- UI produces
Actions - An executor runs actions (DB calls) and returns results
- App state updates with results
- UI produces
For MVP, DB ops are usually fast; still, structure code so you can move DB work to a worker thread if needed.
10.4 Terminal safety
Always restore terminal state:
- on normal exit
- on panic (install panic hook)
- on ctrl-c
11. Error handling conventions
11.1 Typed errors in libraries
Use thiserror in:
- knotter-core
- knotter-store
- knotter-sync
Examples:
FilterParseErrorDomainError(invalid tag, invalid name)StoreError(sqlite error, migration error, not found)SyncError(parse failure, unsupported fields)
11.2 Contextual errors at the edges
In knotter-cli and knotter-tui:
- use
anyhow(or equivalent) for top-level error aggregation and context - convert typed errors to user-friendly messages
11.3 Error message policy
- core/store/sync errors should be actionable but not overly technical
- include debug details only when verbose logging is enabled
12. Configuration and paths
12.1 XDG paths (Linux/Unix)
-
Data:
$XDG_DATA_HOME/knotter/- DB:
knotter.sqlite3
-
Config:
$XDG_CONFIG_HOME/knotter/config.toml
-
Cache:
$XDG_CACHE_HOME/knotter/
Fallbacks:
- if XDG env vars are missing, use standard defaults under
~/.local/share,~/.config,~/.cache.
12.2 Config file (TOML)
Config keys (MVP):
due_soon_days = 7default_cadence_days = 30(optional)notifications.enabled = true/falsenotifications.backend = "stdout" | "desktop" | "email"(email requiresemail-notify)notifications.random_contacts_if_no_reminders = 10(optional; when >0 and reminders are otherwise empty, include random contacts in notifications; max 100)notifications.email.from = "Knotter <knotter@example.com>"notifications.email.to = ["you@example.com"]notifications.email.smtp_host = "smtp.example.com"notifications.email.smtp_port = 587(optional)notifications.email.username = "user@example.com"(optional)notifications.email.password_env = "KNOTTER_SMTP_PASSWORD"(required if username set)notifications.email.subject_prefix = "knotter reminders"(optional)notifications.email.tls = "start-tls" | "tls" | "none"notifications.email.timeout_seconds = 20(optional)interactions.auto_reschedule = true/false(auto-reschedule on interaction add)loops.default_cadence_days = <int>(optional, fallback cadence when no tag matches)loops.strategy = "shortest" | "priority"(how to resolve multiple tag matches)loops.schedule_missing = true/false(schedule when nonext_touchpoint_at)loops.anchor = "now" | "created-at" | "last-interaction"loops.apply_on_tag_change = true/falseloops.override_existing = true/false[[loops.tags]]withtag,cadence_days, optionalpriority
Full config example (all sections + optional fields):
due_soon_days = 7
default_cadence_days = 30
[notifications]
enabled = false
backend = "stdout"
random_contacts_if_no_reminders = 0
[notifications.email]
from = "Knotter <knotter@example.com>"
to = ["you@example.com"]
subject_prefix = "knotter reminders"
smtp_host = "smtp.example.com"
smtp_port = 587
username = "user@example.com"
password_env = "KNOTTER_SMTP_PASSWORD"
tls = "start-tls"
timeout_seconds = 20
[interactions]
auto_reschedule = false
[loops]
default_cadence_days = 180
strategy = "shortest"
schedule_missing = true
anchor = "created-at"
apply_on_tag_change = false
override_existing = false
[[loops.tags]]
tag = "friend"
cadence_days = 90
[[loops.tags]]
tag = "family"
cadence_days = 30
priority = 10
[contacts]
[[contacts.sources]]
name = "gmail"
type = "carddav"
url = "https://example.test/carddav/addressbook/"
username = "user@example.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
tag = "gmail"
[[contacts.sources]]
name = "macos"
type = "macos"
# Optional: import only a named Contacts group (must already exist).
# group = "Friends"
tag = "personal"
[[contacts.email_accounts]]
name = "gmail"
host = "imap.gmail.com"
port = 993
username = "user@gmail.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
mailboxes = ["INBOX", "[Gmail]/Sent Mail"]
identities = ["user@gmail.com"]
merge_policy = "name-or-email"
tls = "tls"
tag = "gmail"
[[contacts.telegram_accounts]]
name = "primary"
api_id = 123456
api_hash_env = "KNOTTER_TELEGRAM_API_HASH"
phone = "+15551234567"
session_path = "/home/user/.local/share/knotter/telegram/primary.session"
merge_policy = "name-or-username"
allowlist_user_ids = [123456789]
snippet_len = 160
tag = "telegram"
Defaults and validation notes:
- When
notifications.enabled = true,notifications.backend = "email"requires a[notifications.email]block and theemail-notifyfeature. - When
notifications.enabled = true,notifications.backend = "desktop"requires thedesktop-notifyfeature. notifications.email.usernameandnotifications.email.password_envmust be set together.- CardDAV sources require
urlandusername;password_envandtagare optional. - Email accounts default to
port = 993,mailboxes = ["INBOX"], andidentities = [username]whenusernameis an email address. - Telegram accounts require
api_id,api_hash_env, andphone.session_pathis optional. - Telegram
merge_policydefaults toname-or-username;snippet_lendefaults to160. - Source/account names are normalized to lowercase and must be unique.
Example loop policy:
[loops]
default_cadence_days = 180
strategy = "shortest"
schedule_missing = true
anchor = "created-at"
apply_on_tag_change = false
override_existing = false
[[loops.tags]]
tag = "friend"
cadence_days = 90
[[loops.tags]]
tag = "family"
cadence_days = 30
priority = 10
Loop precedence:
- Explicit
cadence_dayson a contact takes precedence unlessloops.override_existing = true. - When
cadence_daysis unset, tag rules apply first; the loop default applies when no tag matches. - When
anchor = "last-interaction", scheduling occurs only after an interaction exists. loops.schedule_missing = trueonly schedules contacts that have nonext_touchpoint_at.
Contact source config (optional):
[contacts]
[[contacts.sources]]
name = "gmail"
type = "carddav"
url = "https://example.test/carddav/addressbook/"
username = "user@example.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
tag = "gmail"
[[contacts.sources]]
name = "local"
type = "macos"
# Optional: import only a named Contacts group (must already exist).
# group = "Friends"
tag = "personal"
Notes:
password_envpoints to an environment variable so passwords are not stored in plaintext.nameis case-insensitive and must be unique.
Email account sync config (optional):
[contacts]
[[contacts.email_accounts]]
name = "gmail"
host = "imap.gmail.com"
port = 993
username = "user@gmail.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
mailboxes = ["INBOX", "[Gmail]/Sent Mail"]
identities = ["user@gmail.com"]
merge_policy = "name-or-email" # or "email-only"
tls = "tls" # tls | start-tls | none
tag = "gmail"
Telegram account sync config (optional):
[contacts]
[[contacts.telegram_accounts]]
name = "primary"
api_id = 123456
api_hash_env = "KNOTTER_TELEGRAM_API_HASH"
phone = "+15551234567"
session_path = "/home/user/.local/share/knotter/telegram/primary.session"
merge_policy = "name-or-username" # or "username-only"
allowlist_user_ids = [123456789]
snippet_len = 160
tag = "telegram"
On Unix, config files must be user-readable only (e.g., chmod 600).
Config parsing lives outside core (store/cli/tui).
13. Privacy and security
knotter stores personal notes and contact info. Minimum expectations:
-
DB file should be created with user-only permissions where possible.
-
Do not log full notes by default.
-
Avoid printing private data in error logs.
-
Provide a backup command that uses SQLite's online backup API for a consistent snapshot (safe with WAL).
-
If DAV sync is added:
- never store credentials in plaintext unless explicitly allowed
- prefer OS keyring integration (post-MVP)
14. Testing strategy
14.1 knotter-core tests
- tag normalization tests
- due-state computation tests (today boundaries)
- filter parser tests (valid and invalid cases)
14.2 knotter-store tests
- migration applies from scratch
- CRUD operations
- tag attachment/detachment
- filter query correctness
- due filtering correctness with known timestamps
14.3 knotter-sync tests
- vCard parse + map to core structs
- export vCard is parseable and contains expected fields
- ICS export includes stable UIDs and correct timestamps
- Telegram sync mapping (username normalization + snippet formatting)
14.4 CLI/TUI smoke tests (optional MVP)
-
CLI integration tests for core flows:
- add contact -> tag -> schedule -> remind output contains it
15. Feature flags (recommended)
Feature flags keep optional integrations isolated. Default builds enable CardDAV, email, and Telegram sync, while notification backends remain opt-in:
-
desktop-notifyfeature:- enables desktop notifications backend
-
email-notifyfeature:- enables SMTP notifications backend
-
dav-syncfeature:- enables CardDAV import code (post-MVP sync)
-
email-syncfeature:- enables IMAP email import/sync
-
telegram-syncfeature:- enables Telegram 1:1 import/sync
Use --no-default-features for a no-sync build and re-enable features explicitly.
16. Future: CardDAV/CalDAV sync (post-MVP)
knotter’s sync design should fit this pattern:
-
CardDAV import exists (one-way) behind
dav-sync; full bidirectional sync remains post-MVP. -
A
Sourceabstraction for contacts/events:pull()-> remote itemspush()-> upload local dirty items
-
Local DB remains the source of truth.
-
Sync is explicit (manual command) before adding any background behavior.
-
Conflict handling policy must be deterministic:
- “last updated wins” (simple) or
- mark conflicts and require manual resolution (better, later)
17. Summary of invariants (quick checklist)
- Contact name is non-empty.
- Tags are normalized identically in all layers.
- Timestamps in DB are UTC unix seconds.
- Filter parsing behavior is identical in CLI/TUI.
- Store uses bound parameters only.
- UI never leaves terminal in a broken state.
- Import/export is deterministic and stable (stable ICS UID, consistent VCF mapping).
Appendix: Suggested “kind” string encoding in DB
To avoid schema churn:
-
Store kinds as lowercase strings:
call,text,hangout,email,telegram
-
For
Other(s):- store
other:<normalized>where<normalized>is trimmed and lowercased
- store
-
When reading:
- parse known literals into enum variants
- parse
other:prefix intoOther(String) - unknown values map to
Other(raw)as a forward-compat fallback