Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ EMAIL_IGNORE_TLS=false
EMAIL_USER=your@email.com
EMAIL_PASSWORD=your-password
EMAIL_FROM=no-reply@example.com
ODK_BASE_URL=https://example.com
ODK_PROJECT_ID=99
ODK_FORM_PREREGISTRO=id-form
ODK_FORM_EN_P1=id-form
ODK_FORM_EN_P2=id-form
ODK_FORM_EN_P3=id-form
ODK_USERNAME=your@email.com
ODK_PASSWORD=your-password
EMAIL_TO_RECIVE_ERROR=example@email.com

SEND_PENDING_ON_STARTUP=false

WEEK_DAY='wed'
DAY_HOUR=9
DAY_MIN=17

#REDIS_HOST=redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.env
__pycache__/
*.pyc
config/forms_registry.json
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,44 @@
# odk-mailer
odk-mailer is a lightweight FastAPI microservice that listens to events from ODK Central (via central-webhook) and sends email notifications using aiosmtplib. It is designed to support automated alerts and integrations for public health surveillance and field data collection systems.


## `forms_registry.json` Configuration
File: `config/forms_registry.json`

Initialize it from the example:
```bash
cp config/forms_registry.example.json config/forms_registry.json
```

This file has two sections:
- `routes`: maps each form (`project_id` + `form_id`) to a `handler`.
- `projects`: project-level general settings (cc, name, etc.).

### Base Structure
```json
{
"routes": [],
"projects": []
}
```

### Supported Fields in `routes`
- `project_id` (required): ODK project ID.
- `form_id` (required): ODK form ID.
- `handler` (required): registered handler name.
- `project_name` (optional): display name used in templates/logs.
- `next_form_url` (optional): link inserted into preregistration/backup emails.
- `reminder_enabled` (optional, bool): enables/disables reminders for participants from this route.
- `reminder_url` (optional): URL used by reminders for this route.
- `email_optional` (optional, bool): if email is empty, it is not treated as a warning.
- `cc` (optional, string or list): CC recipients for this route.

### Supported Fields in `projects`
- `project_id` (required): project ID.
- `project_name` (optional): default project display name.
- `cc` (optional, string or list): default CC recipients for all emails in that project.

### Important Rules
- If `project_id`, `form_id`, or `handler` is missing in a route, that route is ignored.
- Final `cc` is combined as: `project.cc + route.cc`.
- A participant enters reminder flow only if their route stored `reminder_enabled=true` and `reminder_url`.
Empty file added audit/__init__.py
Empty file.
30 changes: 30 additions & 0 deletions audit/logging_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import json
import logging
import sys


class JsonFormatter(logging.Formatter):

def format(self, record):
payload = {
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}

if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)

return json.dumps(payload, ensure_ascii=True)


def configure_logging():
level = "INFO"

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())

root = logging.getLogger()
root.handlers.clear()
root.setLevel(level)
root.addHandler(handler)
Empty file added config/__init__.py
Empty file.
109 changes: 109 additions & 0 deletions config/form_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import json
from pathlib import Path


DEFAULT_FORMS_REGISTRY_PATH = Path(__file__).resolve().parent / "forms_registry.json"


def _resolve_url(url):
return (url or "").strip() or None


def _as_bool(value, default=False):
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)


# Normaliza cualquier valor de config a una lista de strings no vacíos.
# Acepta None, string único o lista.
def _to_list(value):
if value is None:
return []
if isinstance(value, str):
item = value.strip()
return [item] if item else []
if isinstance(value, list):
result = []
for item in value:
text = str(item).strip()
if text:
result.append(text)
return result
return []


# Combina CC de proyecto y de ruta, y elimina duplicados sin importar mayúsculas.
def resolve_cc(route_config=None, project_config=None):
route_config = route_config or {}
project_config = project_config or {}
combined = _to_list(project_config.get("cc")) + _to_list(route_config.get("cc"))
seen = set()
out = []
for email in combined:
key = email.lower()
if key in seen:
continue
seen.add(key)
out.append(email)
return out


def resolve_next_form_url(route_config):
route_config = route_config or {}
return _resolve_url(route_config.get("next_form_url"))


def resolve_route_reminder_url(route_config):
route_config = route_config or {}
return _resolve_url(route_config.get("reminder_url"))


def resolve_route_reminder_enabled(route_config):
route_config = route_config or {}
return _as_bool(route_config.get("reminder_enabled"), default=False)


def resolve_reminder_url(project_config):
project_config = project_config or {}
return _resolve_url(project_config.get("reminder_url"))


def resolve_reminder_enabled(project_config):
project_config = project_config or {}
return _as_bool(project_config.get("reminder_enabled"), default=False)


def load_form_routes(path=DEFAULT_FORMS_REGISTRY_PATH):
with path.open("r", encoding="utf-8") as f:
payload = json.load(f)

routes = {}
for entry in payload.get("routes", []):
project_id = str(entry.get("project_id", "")).strip()
form_id = str(entry.get("form_id", "")).strip()
handler_name = str(entry.get("handler", "")).strip()

if not project_id or not form_id or not handler_name:
continue

route_entry = dict(entry)
route_entry["project_id"] = project_id
route_entry["form_id"] = form_id
route_entry["handler"] = handler_name
routes[(project_id, form_id)] = route_entry

projects = {}
for entry in payload.get("projects", []):
project_id = str(entry.get("project_id", "")).strip()
if not project_id:
continue
project_entry = dict(entry)
project_entry["project_id"] = project_id
projects[project_id] = project_entry

return routes, projects
38 changes: 38 additions & 0 deletions config/forms_registry.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"routes": [
{
"project_id": "id_proyecto",
"form_id": "nombre_formulario",
"handler": "nombre_funcion_handler",
"project_name": "nombre_proyecto",
"next_form_url": "https://example.com/laura/encuesta-principal",
"reminder_enabled": true,
"reminder_url": "https://example.com/laura/encuesta-principal"
},
{
"project_id": "id_proyecto",
"form_id": "nombre_formulario",
"handler": "nombre_funcion_handler",
"project_name": "nombre_proyecto"
},
{
"project_id": "id_proyecto",
"form_id": "nombre_formulario",
"handler": "nombre_funcion_handler",
"project_name": "nombre_proyecto",
"reminder_enabled": false,
"email_optional": true
}
],
"projects": [
{
"project_id": "10",
"project_name": "Laura"
},
{
"project_id": "9",
"project_name": "IMVAHA",
"cc": ["example@email.com"]
}
]
}
23 changes: 23 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
services:
mailer:
image: brunoxiii/odk-mailer_cache_redis_v2:latest
ports:
- "4000:4000"
env_file:
- .env
depends_on:
- redis
restart: unless-stopped

redis:
image: redis:7-alpine
container_name: redis-ODK-Mailer
ports:
- "6380:6379"
volumes:
- redis_data:/data
restart: unless-stopped
command: redis-server --appendonly yes --appendfsync everysec

volumes:
redis_data:
1 change: 1 addition & 0 deletions handler/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Paquete de handlers por proyecto.
13 changes: 13 additions & 0 deletions handler/forms_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from handler.imvaha import handle_imvaha_preregistro
from handler.laura import (
actualizar_email_participante,
handle_laura_encuesta_principal,
handle_laura_preregistro,
)


HANDLER_REGISTRY = {
"laura_preregistro": handle_laura_preregistro,
"laura_encuesta_principal": handle_laura_encuesta_principal,
"imvaha_preregistro": handle_imvaha_preregistro,
}
40 changes: 40 additions & 0 deletions handler/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
def _get_nested(payload, *keys):
current = payload
for key in keys:
if not isinstance(current, dict):
return None
current = current.get(key)
if current is None:
return None
return current


def _save_participant(
redis_client,
participant_id,
email,
estado,
project_id,
project_name=None,
next_form_url=None,
reminder_enabled=None,
reminder_url=None,
):
participante_data = {
"email": email,
"estado": estado,
"backup": "0",
}
if project_id:
participante_data["project_id"] = project_id
if project_name:
participante_data["project_name"] = project_name
if next_form_url:
participante_data["next_form_url"] = next_form_url
if reminder_enabled is not None:
participante_data["reminder_enabled"] = "1" if bool(reminder_enabled) else "0"
if reminder_url:
participante_data["reminder_url"] = reminder_url

redis_client.hset(f"participante:{participant_id}", mapping=participante_data)
redis_client.expire(f"participante:{participant_id}", 3888000)
Loading