Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
CRM_SYNC_PAGE_SIZE=200
# Optional: restrict Docuseal agreements to this template id.
# If unset, Docuseal agreement processing is ignored.
DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID=

# Discord bot (required for bot runtime)
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
137 changes: 135 additions & 2 deletions apps/worker/src/five08/backend/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,22 @@
from five08.worker.config import settings
from five08.worker.db_migrations import run_job_migrations
from five08.worker.dispatcher import build_queue_client
from five08.worker.masking import mask_email
from five08.worker.jobs import (
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
)
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload
from five08.worker.models import (
AuditEventPayload,
DocusealWebhookPayload,
EspoCRMWebhookPayload,
)

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool:
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
return False

# TODO: security-hardening: move webhook auth to per-webhook generated secrets
# sourced from an admin dashboard with copyable callback URLs.
provided_secret = request.headers.get("X-API-Secret", "")
return secrets.compare_digest(provided_secret, settings.api_shared_secret)
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
return True

return False


def _extract_idempotency_key(value: object) -> str | None:
Expand DownExpand Up@@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
)


async def docuseal_webhook_handler(request: Request) -> JSONResponse:
"""Process a Docuseal form.completed webhook and enqueue agreement job."""
if not _is_authorized(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)

try:
payload_data = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)

if not isinstance(payload_data, dict):
return JSONResponse({"error": "payload_must_be_object"}, status_code=400)

try:
payload = DocusealWebhookPayload.model_validate(payload_data)
except (ValidationError, TypeError) as exc:
return JSONResponse(
{"error": "invalid_payload", "detail": str(exc)},
status_code=400,
)

if payload.event_type != "form.completed":
return JSONResponse(
{
"status": "ignored",
"reason": f"unhandled event_type: {payload.event_type}",
},
status_code=200,
)

submitter = payload.data
submission_id = (
submitter.submission_id if submitter.submission_id is not None else submitter.id
)

template_filter_id = settings.docuseal_member_agreement_template_id
if template_filter_id is None:
logger.info("Ignoring Docuseal agreement webhook: template filter is unset")
return JSONResponse(
{
"status": "ignored",
"reason": "template_filter_not_configured",
},
status_code=200,
)

template_id = submitter.template.id if submitter.template else None
if template_id != template_filter_id:
logger.info(
"Ignoring Docuseal agreement webhook for unmatched template_id=%s"
" expected=%s submission_id=%s",
template_id,
template_filter_id,
submission_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "template_mismatch",
"submission_id": submission_id,
},
status_code=200,
)

email = (submitter.email or "").strip()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
completed_at = submitter.completed_at or payload.timestamp
if isinstance(completed_at, str):
completed_at = completed_at.strip()
if not isinstance(completed_at, str) or not completed_at:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

try:
datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

if not email:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

masked_email = mask_email(email)

queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(email, completed_at, submission_id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submission_id}",
)
except Exception:
logger.exception(
"Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return JSONResponse({"error": "enqueue_failed"}, status_code=503)

logger.info(
"Enqueued Docuseal agreement job job_id=%s masked_email=%s",
job.id,
masked_email,
)
return JSONResponse(
{
"status": "queued",
"source": "docuseal",
"job_id": job.id,
"masked_email": masked_email,
"submission_id": submission_id,
},
status_code=202,
)


async def audit_event_handler(request: Request) -> JSONResponse:
"""Persist one human audit event."""
if not _is_authorized(request):
Expand DownExpand Up@@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
espocrm_people_sync_webhook_handler,
methods=["POST"],
)
app.add_api_route(
"/webhooks/docuseal",
docuseal_webhook_handler,
methods=["POST"],
)
app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"])

app.add_api_route(
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/five08/worker/actors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
Expand All@@ -48,6 +49,7 @@
apply_resume_profile_job.__name__: apply_resume_profile_job,
sync_people_from_crm_job.__name__: sync_people_from_crm_job,
sync_person_from_crm_job.__name__: sync_person_from_crm_job,
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
}


Expand Down
20 changes: 19 additions & 1 deletion apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import field_validator, model_validator

from five08.settings import SharedSettings

Expand All@@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings):
openai_model: str = "gpt-4o-mini"
resume_ai_model: str = "gpt-4o-mini"
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

max_file_size_mb: int = 10
allowed_file_types: str = "pdf,doc,docx,txt"
Expand DownExpand Up@@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings":
self.auth_cookie_samesite = normalized
return self

@field_validator("docuseal_member_agreement_template_id", mode="before")
@classmethod
def _normalize_docuseal_member_agreement_template_id(
cls,
value: object,
) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return None
return int(normalized)
raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer")

@property
def allowed_file_extensions(self) -> set[str]:
"""Allowed resume file extensions."""
Expand Down
98 changes: 98 additions & 0 deletions apps/worker/src/five08/worker/crm/docuseal_processor.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Docuseal member agreement processing workflow."""

import logging
from typing import Any

from five08.clients.espo import EspoAPI, EspoAPIError
from five08.worker.config import settings
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)


class DocusealAgreementProcessor:
"""Look up a CRM contact by email and mark their member agreement as signed."""

def __init__(self) -> None:
api_url = settings.espo_base_url.rstrip("/") + "/api/v1"
self.api = EspoAPI(api_url, settings.espo_api_key)

def process_agreement(
self,
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Search for the signer by email and update cMemberAgreementSignedAt."""
masked_email = mask_email(email)

try:
result = self.api.request(
"GET",
"Contact",
{
"where": [
{
"type": "equals",
"attribute": "emailAddress",
"value": email,
}
],
"maxSize": 1,
"select": "id,name,emailAddress",
},
)
except EspoAPIError as exc:
logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc)
return {
"success": False,
"masked_email": masked_email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning(
"No CRM contact found for masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return {
"success": False,
"masked_email": masked_email,
"error": "contact_not_found",
}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{
"cMemberAgreementSignedAt": completed_at,
},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"masked_email": masked_email,
"submission_id": submission_id,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s masked_email=%s",
contact_id,
masked_email,
)
return {
"success": True,
"masked_email": masked_email,
"contact_id": contact_id,
"submission_id": submission_id,
"completed_at": completed_at,
}
17 changes: 17 additions & 0 deletions apps/worker/src/five08/worker/jobs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@
from datetime import datetime, timezone
from typing import Any

from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor
from five08.worker.crm.people_sync import PeopleSyncProcessor
from five08.worker.crm.processor import ContactSkillsProcessor
from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -68,6 +70,21 @@ def apply_resume_profile_job(
return result.model_dump()


def process_docuseal_agreement_job(
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Mark a CRM contact as having signed the member agreement via Docuseal."""
logger.info(
"Processing Docuseal agreement job masked_email=%s submission_id=%s",
mask_email(email),
submission_id,
)
processor = DocusealAgreementProcessor()
return processor.process_agreement(email, completed_at, submission_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_people_from_crm_job() -> dict[str, Any]:
"""Sync a full contacts page-set from CRM into the local people cache."""
logger.info("Processing CRM people full-sync job")
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/five08/worker/masking.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
"""PII masking helpers used across worker modules."""


def mask_email(email: str) -> str:
"""Return a deterministic redacted email representation for logs and responses."""
local, at, domain = email.partition("@")
if not at:
return "***"

masked_local = (local[:1] if local else "*") + "***"

if not domain:
return f"{masked_local}@****..."

return f"{masked_local}@{domain[:1]}****..."
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use hash-based masking instead of partial-character exposure.

This currently reveals email initials (local[:1], domain[:1]), which weakens PII redaction and does not match a “masked email hash” requirement.

🔧 Proposed fix
+import hashlib+
def mask_email(email: str) -> str:
- """Return a deterministic redacted email representation for logs and responses."""- local, at, domain = email.partition("@")- if not at:- return "***"-- masked_local = (local[:1] if local else "*") + "***"-- if not domain:- return f"{masked_local}@****..."-- return f"{masked_local}@{domain[:1]}****..."+ """Return deterministic non-reversible email token for logs/responses."""+ normalized = email.strip().lower()+ if "@" not in normalized:+ return "email#invalid"+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]+ return f"email#{digest}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/masking.py` around lines 10 - 15, The code
currently exposes initials via masked_local = (local[:1] if local else "*") +
"***" and domain[:1], so replace character-based masking with a deterministic
hash-based mask: compute a stable hash (e.g., hashlib.sha256) of the
identifiable input (either the whole email or local+domain) and use a short
prefix of the hex digest (e.g., first 8 chars) as the visible token instead of
local[:1]/domain[:1]; update the return expressions that currently use
masked_local and domain[:1] to use the hash prefix (for example return
f"{hash_prefix}@****..." or split into hash_local and hash_domain prefixes if
you need both parts), and ensure the hashing is consistent (use .encode() on the
string and hex digest) so tests relying on deterministic masked values pass.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
CRM_SYNC_PAGE_SIZE=200
# Optional: restrict Docuseal agreements to this template id.
# If unset, Docuseal agreement processing is ignored.
DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID=

# Discord bot (required for bot runtime)
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
137 changes: 135 additions & 2 deletions apps/worker/src/five08/backend/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,22 @@
from five08.worker.config import settings
from five08.worker.db_migrations import run_job_migrations
from five08.worker.dispatcher import build_queue_client
from five08.worker.masking import mask_email
from five08.worker.jobs import (
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
)
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload
from five08.worker.models import (
AuditEventPayload,
DocusealWebhookPayload,
EspoCRMWebhookPayload,
)

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool:
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
return False

# TODO: security-hardening: move webhook auth to per-webhook generated secrets
# sourced from an admin dashboard with copyable callback URLs.
provided_secret = request.headers.get("X-API-Secret", "")
return secrets.compare_digest(provided_secret, settings.api_shared_secret)
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
return True

return False


def _extract_idempotency_key(value: object) -> str | None:
Expand DownExpand Up@@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
)


async def docuseal_webhook_handler(request: Request) -> JSONResponse:
"""Process a Docuseal form.completed webhook and enqueue agreement job."""
if not _is_authorized(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)

try:
payload_data = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)

if not isinstance(payload_data, dict):
return JSONResponse({"error": "payload_must_be_object"}, status_code=400)

try:
payload = DocusealWebhookPayload.model_validate(payload_data)
except (ValidationError, TypeError) as exc:
return JSONResponse(
{"error": "invalid_payload", "detail": str(exc)},
status_code=400,
)

if payload.event_type != "form.completed":
return JSONResponse(
{
"status": "ignored",
"reason": f"unhandled event_type: {payload.event_type}",
},
status_code=200,
)

submitter = payload.data
submission_id = (
submitter.submission_id if submitter.submission_id is not None else submitter.id
)

template_filter_id = settings.docuseal_member_agreement_template_id
if template_filter_id is None:
logger.info("Ignoring Docuseal agreement webhook: template filter is unset")
return JSONResponse(
{
"status": "ignored",
"reason": "template_filter_not_configured",
},
status_code=200,
)

template_id = submitter.template.id if submitter.template else None
if template_id != template_filter_id:
logger.info(
"Ignoring Docuseal agreement webhook for unmatched template_id=%s"
" expected=%s submission_id=%s",
template_id,
template_filter_id,
submission_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "template_mismatch",
"submission_id": submission_id,
},
status_code=200,
)

email = (submitter.email or "").strip()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
completed_at = submitter.completed_at or payload.timestamp
if isinstance(completed_at, str):
completed_at = completed_at.strip()
if not isinstance(completed_at, str) or not completed_at:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

try:
datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

if not email:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

masked_email = mask_email(email)

queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(email, completed_at, submission_id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submission_id}",
)
except Exception:
logger.exception(
"Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return JSONResponse({"error": "enqueue_failed"}, status_code=503)

logger.info(
"Enqueued Docuseal agreement job job_id=%s masked_email=%s",
job.id,
masked_email,
)
return JSONResponse(
{
"status": "queued",
"source": "docuseal",
"job_id": job.id,
"masked_email": masked_email,
"submission_id": submission_id,
},
status_code=202,
)


async def audit_event_handler(request: Request) -> JSONResponse:
"""Persist one human audit event."""
if not _is_authorized(request):
Expand DownExpand Up@@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
espocrm_people_sync_webhook_handler,
methods=["POST"],
)
app.add_api_route(
"/webhooks/docuseal",
docuseal_webhook_handler,
methods=["POST"],
)
app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"])

app.add_api_route(
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/five08/worker/actors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
Expand All@@ -48,6 +49,7 @@
apply_resume_profile_job.__name__: apply_resume_profile_job,
sync_people_from_crm_job.__name__: sync_people_from_crm_job,
sync_person_from_crm_job.__name__: sync_person_from_crm_job,
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
}


Expand Down
20 changes: 19 additions & 1 deletion apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import field_validator, model_validator

from five08.settings import SharedSettings

Expand All@@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings):
openai_model: str = "gpt-4o-mini"
resume_ai_model: str = "gpt-4o-mini"
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

max_file_size_mb: int = 10
allowed_file_types: str = "pdf,doc,docx,txt"
Expand DownExpand Up@@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings":
self.auth_cookie_samesite = normalized
return self

@field_validator("docuseal_member_agreement_template_id", mode="before")
@classmethod
def _normalize_docuseal_member_agreement_template_id(
cls,
value: object,
) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return None
return int(normalized)
raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer")

@property
def allowed_file_extensions(self) -> set[str]:
"""Allowed resume file extensions."""
Expand Down
98 changes: 98 additions & 0 deletions apps/worker/src/five08/worker/crm/docuseal_processor.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Docuseal member agreement processing workflow."""

import logging
from typing import Any

from five08.clients.espo import EspoAPI, EspoAPIError
from five08.worker.config import settings
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)


class DocusealAgreementProcessor:
"""Look up a CRM contact by email and mark their member agreement as signed."""

def __init__(self) -> None:
api_url = settings.espo_base_url.rstrip("/") + "/api/v1"
self.api = EspoAPI(api_url, settings.espo_api_key)

def process_agreement(
self,
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Search for the signer by email and update cMemberAgreementSignedAt."""
masked_email = mask_email(email)

try:
result = self.api.request(
"GET",
"Contact",
{
"where": [
{
"type": "equals",
"attribute": "emailAddress",
"value": email,
}
],
"maxSize": 1,
"select": "id,name,emailAddress",
},
)
except EspoAPIError as exc:
logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc)
return {
"success": False,
"masked_email": masked_email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning(
"No CRM contact found for masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return {
"success": False,
"masked_email": masked_email,
"error": "contact_not_found",
}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{
"cMemberAgreementSignedAt": completed_at,
},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"masked_email": masked_email,
"submission_id": submission_id,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s masked_email=%s",
contact_id,
masked_email,
)
return {
"success": True,
"masked_email": masked_email,
"contact_id": contact_id,
"submission_id": submission_id,
"completed_at": completed_at,
}
17 changes: 17 additions & 0 deletions apps/worker/src/five08/worker/jobs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@
from datetime import datetime, timezone
from typing import Any

from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor
from five08.worker.crm.people_sync import PeopleSyncProcessor
from five08.worker.crm.processor import ContactSkillsProcessor
from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -68,6 +70,21 @@ def apply_resume_profile_job(
return result.model_dump()


def process_docuseal_agreement_job(
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Mark a CRM contact as having signed the member agreement via Docuseal."""
logger.info(
"Processing Docuseal agreement job masked_email=%s submission_id=%s",
mask_email(email),
submission_id,
)
processor = DocusealAgreementProcessor()
return processor.process_agreement(email, completed_at, submission_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_people_from_crm_job() -> dict[str, Any]:
"""Sync a full contacts page-set from CRM into the local people cache."""
logger.info("Processing CRM people full-sync job")
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/five08/worker/masking.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
"""PII masking helpers used across worker modules."""


def mask_email(email: str) -> str:
"""Return a deterministic redacted email representation for logs and responses."""
local, at, domain = email.partition("@")
if not at:
return "***"

masked_local = (local[:1] if local else "*") + "***"

if not domain:
return f"{masked_local}@****..."

return f"{masked_local}@{domain[:1]}****..."
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use hash-based masking instead of partial-character exposure.

This currently reveals email initials (local[:1], domain[:1]), which weakens PII redaction and does not match a “masked email hash” requirement.

🔧 Proposed fix
+import hashlib+
def mask_email(email: str) -> str:
- """Return a deterministic redacted email representation for logs and responses."""- local, at, domain = email.partition("@")- if not at:- return "***"-- masked_local = (local[:1] if local else "*") + "***"-- if not domain:- return f"{masked_local}@****..."-- return f"{masked_local}@{domain[:1]}****..."+ """Return deterministic non-reversible email token for logs/responses."""+ normalized = email.strip().lower()+ if "@" not in normalized:+ return "email#invalid"+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]+ return f"email#{digest}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/masking.py` around lines 10 - 15, The code
currently exposes initials via masked_local = (local[:1] if local else "*") +
"***" and domain[:1], so replace character-based masking with a deterministic
hash-based mask: compute a stable hash (e.g., hashlib.sha256) of the
identifiable input (either the whole email or local+domain) and use a short
prefix of the hex digest (e.g., first 8 chars) as the visible token instead of
local[:1]/domain[:1]; update the return expressions that currently use
masked_local and domain[:1] to use the hash prefix (for example return
f"{hash_prefix}@****..." or split into hash_local and hash_domain prefixes if
you need both parts), and ensure the hashing is consistent (use .encode() on the
string and hex digest) so tests relying on deterministic masked values pass.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
CRM_SYNC_PAGE_SIZE=200
# Optional: restrict Docuseal agreements to this template id.
# If unset, Docuseal agreement processing is ignored.
DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID=

# Discord bot (required for bot runtime)
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
137 changes: 135 additions & 2 deletions apps/worker/src/five08/backend/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,22 @@
from five08.worker.config import settings
from five08.worker.db_migrations import run_job_migrations
from five08.worker.dispatcher import build_queue_client
from five08.worker.masking import mask_email
from five08.worker.jobs import (
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
)
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload
from five08.worker.models import (
AuditEventPayload,
DocusealWebhookPayload,
EspoCRMWebhookPayload,
)

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool:
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
return False

# TODO: security-hardening: move webhook auth to per-webhook generated secrets
# sourced from an admin dashboard with copyable callback URLs.
provided_secret = request.headers.get("X-API-Secret", "")
return secrets.compare_digest(provided_secret, settings.api_shared_secret)
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
return True

return False


def _extract_idempotency_key(value: object) -> str | None:
Expand DownExpand Up@@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
)


async def docuseal_webhook_handler(request: Request) -> JSONResponse:
"""Process a Docuseal form.completed webhook and enqueue agreement job."""
if not _is_authorized(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)

try:
payload_data = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)

if not isinstance(payload_data, dict):
return JSONResponse({"error": "payload_must_be_object"}, status_code=400)

try:
payload = DocusealWebhookPayload.model_validate(payload_data)
except (ValidationError, TypeError) as exc:
return JSONResponse(
{"error": "invalid_payload", "detail": str(exc)},
status_code=400,
)

if payload.event_type != "form.completed":
return JSONResponse(
{
"status": "ignored",
"reason": f"unhandled event_type: {payload.event_type}",
},
status_code=200,
)

submitter = payload.data
submission_id = (
submitter.submission_id if submitter.submission_id is not None else submitter.id
)

template_filter_id = settings.docuseal_member_agreement_template_id
if template_filter_id is None:
logger.info("Ignoring Docuseal agreement webhook: template filter is unset")
return JSONResponse(
{
"status": "ignored",
"reason": "template_filter_not_configured",
},
status_code=200,
)

template_id = submitter.template.id if submitter.template else None
if template_id != template_filter_id:
logger.info(
"Ignoring Docuseal agreement webhook for unmatched template_id=%s"
" expected=%s submission_id=%s",
template_id,
template_filter_id,
submission_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "template_mismatch",
"submission_id": submission_id,
},
status_code=200,
)

email = (submitter.email or "").strip()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
completed_at = submitter.completed_at or payload.timestamp
if isinstance(completed_at, str):
completed_at = completed_at.strip()
if not isinstance(completed_at, str) or not completed_at:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

try:
datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

if not email:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

masked_email = mask_email(email)

queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(email, completed_at, submission_id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submission_id}",
)
except Exception:
logger.exception(
"Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return JSONResponse({"error": "enqueue_failed"}, status_code=503)

logger.info(
"Enqueued Docuseal agreement job job_id=%s masked_email=%s",
job.id,
masked_email,
)
return JSONResponse(
{
"status": "queued",
"source": "docuseal",
"job_id": job.id,
"masked_email": masked_email,
"submission_id": submission_id,
},
status_code=202,
)


async def audit_event_handler(request: Request) -> JSONResponse:
"""Persist one human audit event."""
if not _is_authorized(request):
Expand DownExpand Up@@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
espocrm_people_sync_webhook_handler,
methods=["POST"],
)
app.add_api_route(
"/webhooks/docuseal",
docuseal_webhook_handler,
methods=["POST"],
)
app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"])

app.add_api_route(
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/five08/worker/actors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
Expand All@@ -48,6 +49,7 @@
apply_resume_profile_job.__name__: apply_resume_profile_job,
sync_people_from_crm_job.__name__: sync_people_from_crm_job,
sync_person_from_crm_job.__name__: sync_person_from_crm_job,
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
}


Expand Down
20 changes: 19 additions & 1 deletion apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import field_validator, model_validator

from five08.settings import SharedSettings

Expand All@@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings):
openai_model: str = "gpt-4o-mini"
resume_ai_model: str = "gpt-4o-mini"
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

max_file_size_mb: int = 10
allowed_file_types: str = "pdf,doc,docx,txt"
Expand DownExpand Up@@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings":
self.auth_cookie_samesite = normalized
return self

@field_validator("docuseal_member_agreement_template_id", mode="before")
@classmethod
def _normalize_docuseal_member_agreement_template_id(
cls,
value: object,
) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return None
return int(normalized)
raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer")

@property
def allowed_file_extensions(self) -> set[str]:
"""Allowed resume file extensions."""
Expand Down
98 changes: 98 additions & 0 deletions apps/worker/src/five08/worker/crm/docuseal_processor.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Docuseal member agreement processing workflow."""

import logging
from typing import Any

from five08.clients.espo import EspoAPI, EspoAPIError
from five08.worker.config import settings
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)


class DocusealAgreementProcessor:
"""Look up a CRM contact by email and mark their member agreement as signed."""

def __init__(self) -> None:
api_url = settings.espo_base_url.rstrip("/") + "/api/v1"
self.api = EspoAPI(api_url, settings.espo_api_key)

def process_agreement(
self,
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Search for the signer by email and update cMemberAgreementSignedAt."""
masked_email = mask_email(email)

try:
result = self.api.request(
"GET",
"Contact",
{
"where": [
{
"type": "equals",
"attribute": "emailAddress",
"value": email,
}
],
"maxSize": 1,
"select": "id,name,emailAddress",
},
)
except EspoAPIError as exc:
logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc)
return {
"success": False,
"masked_email": masked_email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning(
"No CRM contact found for masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return {
"success": False,
"masked_email": masked_email,
"error": "contact_not_found",
}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{
"cMemberAgreementSignedAt": completed_at,
},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"masked_email": masked_email,
"submission_id": submission_id,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s masked_email=%s",
contact_id,
masked_email,
)
return {
"success": True,
"masked_email": masked_email,
"contact_id": contact_id,
"submission_id": submission_id,
"completed_at": completed_at,
}
17 changes: 17 additions & 0 deletions apps/worker/src/five08/worker/jobs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@
from datetime import datetime, timezone
from typing import Any

from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor
from five08.worker.crm.people_sync import PeopleSyncProcessor
from five08.worker.crm.processor import ContactSkillsProcessor
from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -68,6 +70,21 @@ def apply_resume_profile_job(
return result.model_dump()


def process_docuseal_agreement_job(
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Mark a CRM contact as having signed the member agreement via Docuseal."""
logger.info(
"Processing Docuseal agreement job masked_email=%s submission_id=%s",
mask_email(email),
submission_id,
)
processor = DocusealAgreementProcessor()
return processor.process_agreement(email, completed_at, submission_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_people_from_crm_job() -> dict[str, Any]:
"""Sync a full contacts page-set from CRM into the local people cache."""
logger.info("Processing CRM people full-sync job")
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/five08/worker/masking.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
"""PII masking helpers used across worker modules."""


def mask_email(email: str) -> str:
"""Return a deterministic redacted email representation for logs and responses."""
local, at, domain = email.partition("@")
if not at:
return "***"

masked_local = (local[:1] if local else "*") + "***"

if not domain:
return f"{masked_local}@****..."

return f"{masked_local}@{domain[:1]}****..."
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use hash-based masking instead of partial-character exposure.

This currently reveals email initials (local[:1], domain[:1]), which weakens PII redaction and does not match a “masked email hash” requirement.

🔧 Proposed fix
+import hashlib+
def mask_email(email: str) -> str:
- """Return a deterministic redacted email representation for logs and responses."""- local, at, domain = email.partition("@")- if not at:- return "***"-- masked_local = (local[:1] if local else "*") + "***"-- if not domain:- return f"{masked_local}@****..."-- return f"{masked_local}@{domain[:1]}****..."+ """Return deterministic non-reversible email token for logs/responses."""+ normalized = email.strip().lower()+ if "@" not in normalized:+ return "email#invalid"+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]+ return f"email#{digest}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/masking.py` around lines 10 - 15, The code
currently exposes initials via masked_local = (local[:1] if local else "*") +
"***" and domain[:1], so replace character-based masking with a deterministic
hash-based mask: compute a stable hash (e.g., hashlib.sha256) of the
identifiable input (either the whole email or local+domain) and use a short
prefix of the hex digest (e.g., first 8 chars) as the visible token instead of
local[:1]/domain[:1]; update the return expressions that currently use
masked_local and domain[:1] to use the hash prefix (for example return
f"{hash_prefix}@****..." or split into hash_local and hash_domain prefixes if
you need both parts), and ensure the hashing is consistent (use .encode() on the
string and hex digest) so tests relying on deterministic masked values pass.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
CRM_SYNC_PAGE_SIZE=200
# Optional: restrict Docuseal agreements to this template id.
# If unset, Docuseal agreement processing is ignored.
DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID=

# Discord bot (required for bot runtime)
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
137 changes: 135 additions & 2 deletions apps/worker/src/five08/backend/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,22 @@
from five08.worker.config import settings
from five08.worker.db_migrations import run_job_migrations
from five08.worker.dispatcher import build_queue_client
from five08.worker.masking import mask_email
from five08.worker.jobs import (
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
)
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload
from five08.worker.models import (
AuditEventPayload,
DocusealWebhookPayload,
EspoCRMWebhookPayload,
)

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool:
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
return False

# TODO: security-hardening: move webhook auth to per-webhook generated secrets
# sourced from an admin dashboard with copyable callback URLs.
provided_secret = request.headers.get("X-API-Secret", "")
return secrets.compare_digest(provided_secret, settings.api_shared_secret)
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
return True

return False


def _extract_idempotency_key(value: object) -> str | None:
Expand DownExpand Up@@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
)


async def docuseal_webhook_handler(request: Request) -> JSONResponse:
"""Process a Docuseal form.completed webhook and enqueue agreement job."""
if not _is_authorized(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)

try:
payload_data = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)

if not isinstance(payload_data, dict):
return JSONResponse({"error": "payload_must_be_object"}, status_code=400)

try:
payload = DocusealWebhookPayload.model_validate(payload_data)
except (ValidationError, TypeError) as exc:
return JSONResponse(
{"error": "invalid_payload", "detail": str(exc)},
status_code=400,
)

if payload.event_type != "form.completed":
return JSONResponse(
{
"status": "ignored",
"reason": f"unhandled event_type: {payload.event_type}",
},
status_code=200,
)

submitter = payload.data
submission_id = (
submitter.submission_id if submitter.submission_id is not None else submitter.id
)

template_filter_id = settings.docuseal_member_agreement_template_id
if template_filter_id is None:
logger.info("Ignoring Docuseal agreement webhook: template filter is unset")
return JSONResponse(
{
"status": "ignored",
"reason": "template_filter_not_configured",
},
status_code=200,
)

template_id = submitter.template.id if submitter.template else None
if template_id != template_filter_id:
logger.info(
"Ignoring Docuseal agreement webhook for unmatched template_id=%s"
" expected=%s submission_id=%s",
template_id,
template_filter_id,
submission_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "template_mismatch",
"submission_id": submission_id,
},
status_code=200,
)

email = (submitter.email or "").strip()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
completed_at = submitter.completed_at or payload.timestamp
if isinstance(completed_at, str):
completed_at = completed_at.strip()
if not isinstance(completed_at, str) or not completed_at:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

try:
datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

if not email:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

masked_email = mask_email(email)

queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(email, completed_at, submission_id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submission_id}",
)
except Exception:
logger.exception(
"Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return JSONResponse({"error": "enqueue_failed"}, status_code=503)

logger.info(
"Enqueued Docuseal agreement job job_id=%s masked_email=%s",
job.id,
masked_email,
)
return JSONResponse(
{
"status": "queued",
"source": "docuseal",
"job_id": job.id,
"masked_email": masked_email,
"submission_id": submission_id,
},
status_code=202,
)


async def audit_event_handler(request: Request) -> JSONResponse:
"""Persist one human audit event."""
if not _is_authorized(request):
Expand DownExpand Up@@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
espocrm_people_sync_webhook_handler,
methods=["POST"],
)
app.add_api_route(
"/webhooks/docuseal",
docuseal_webhook_handler,
methods=["POST"],
)
app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"])

app.add_api_route(
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/five08/worker/actors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
Expand All@@ -48,6 +49,7 @@
apply_resume_profile_job.__name__: apply_resume_profile_job,
sync_people_from_crm_job.__name__: sync_people_from_crm_job,
sync_person_from_crm_job.__name__: sync_person_from_crm_job,
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
}


Expand Down
20 changes: 19 additions & 1 deletion apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import field_validator, model_validator

from five08.settings import SharedSettings

Expand All@@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings):
openai_model: str = "gpt-4o-mini"
resume_ai_model: str = "gpt-4o-mini"
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

max_file_size_mb: int = 10
allowed_file_types: str = "pdf,doc,docx,txt"
Expand DownExpand Up@@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings":
self.auth_cookie_samesite = normalized
return self

@field_validator("docuseal_member_agreement_template_id", mode="before")
@classmethod
def _normalize_docuseal_member_agreement_template_id(
cls,
value: object,
) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return None
return int(normalized)
raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer")

@property
def allowed_file_extensions(self) -> set[str]:
"""Allowed resume file extensions."""
Expand Down
98 changes: 98 additions & 0 deletions apps/worker/src/five08/worker/crm/docuseal_processor.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Docuseal member agreement processing workflow."""

import logging
from typing import Any

from five08.clients.espo import EspoAPI, EspoAPIError
from five08.worker.config import settings
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)


class DocusealAgreementProcessor:
"""Look up a CRM contact by email and mark their member agreement as signed."""

def __init__(self) -> None:
api_url = settings.espo_base_url.rstrip("/") + "/api/v1"
self.api = EspoAPI(api_url, settings.espo_api_key)

def process_agreement(
self,
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Search for the signer by email and update cMemberAgreementSignedAt."""
masked_email = mask_email(email)

try:
result = self.api.request(
"GET",
"Contact",
{
"where": [
{
"type": "equals",
"attribute": "emailAddress",
"value": email,
}
],
"maxSize": 1,
"select": "id,name,emailAddress",
},
)
except EspoAPIError as exc:
logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc)
return {
"success": False,
"masked_email": masked_email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning(
"No CRM contact found for masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return {
"success": False,
"masked_email": masked_email,
"error": "contact_not_found",
}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{
"cMemberAgreementSignedAt": completed_at,
},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"masked_email": masked_email,
"submission_id": submission_id,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s masked_email=%s",
contact_id,
masked_email,
)
return {
"success": True,
"masked_email": masked_email,
"contact_id": contact_id,
"submission_id": submission_id,
"completed_at": completed_at,
}
17 changes: 17 additions & 0 deletions apps/worker/src/five08/worker/jobs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@
from datetime import datetime, timezone
from typing import Any

from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor
from five08.worker.crm.people_sync import PeopleSyncProcessor
from five08.worker.crm.processor import ContactSkillsProcessor
from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -68,6 +70,21 @@ def apply_resume_profile_job(
return result.model_dump()


def process_docuseal_agreement_job(
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Mark a CRM contact as having signed the member agreement via Docuseal."""
logger.info(
"Processing Docuseal agreement job masked_email=%s submission_id=%s",
mask_email(email),
submission_id,
)
processor = DocusealAgreementProcessor()
return processor.process_agreement(email, completed_at, submission_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_people_from_crm_job() -> dict[str, Any]:
"""Sync a full contacts page-set from CRM into the local people cache."""
logger.info("Processing CRM people full-sync job")
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/five08/worker/masking.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
"""PII masking helpers used across worker modules."""


def mask_email(email: str) -> str:
"""Return a deterministic redacted email representation for logs and responses."""
local, at, domain = email.partition("@")
if not at:
return "***"

masked_local = (local[:1] if local else "*") + "***"

if not domain:
return f"{masked_local}@****..."

return f"{masked_local}@{domain[:1]}****..."
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use hash-based masking instead of partial-character exposure.

This currently reveals email initials (local[:1], domain[:1]), which weakens PII redaction and does not match a “masked email hash” requirement.

🔧 Proposed fix
+import hashlib+
def mask_email(email: str) -> str:
- """Return a deterministic redacted email representation for logs and responses."""- local, at, domain = email.partition("@")- if not at:- return "***"-- masked_local = (local[:1] if local else "*") + "***"-- if not domain:- return f"{masked_local}@****..."-- return f"{masked_local}@{domain[:1]}****..."+ """Return deterministic non-reversible email token for logs/responses."""+ normalized = email.strip().lower()+ if "@" not in normalized:+ return "email#invalid"+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]+ return f"email#{digest}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/masking.py` around lines 10 - 15, The code
currently exposes initials via masked_local = (local[:1] if local else "*") +
"***" and domain[:1], so replace character-based masking with a deterministic
hash-based mask: compute a stable hash (e.g., hashlib.sha256) of the
identifiable input (either the whole email or local+domain) and use a short
prefix of the hex digest (e.g., first 8 chars) as the visible token instead of
local[:1]/domain[:1]; update the return expressions that currently use
masked_local and domain[:1] to use the hash prefix (for example return
f"{hash_prefix}@****..." or split into hash_local and hash_domain prefixes if
you need both parts), and ensure the hashing is consistent (use .encode() on the
string and hex digest) so tests relying on deterministic masked values pass.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
CRM_SYNC_PAGE_SIZE=200
# Optional: restrict Docuseal agreements to this template id.
# If unset, Docuseal agreement processing is ignored.
DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID=

# Discord bot (required for bot runtime)
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
137 changes: 135 additions & 2 deletions apps/worker/src/five08/backend/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,22 @@
from five08.worker.config import settings
from five08.worker.db_migrations import run_job_migrations
from five08.worker.dispatcher import build_queue_client
from five08.worker.masking import mask_email
from five08.worker.jobs import (
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
)
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload
from five08.worker.models import (
AuditEventPayload,
DocusealWebhookPayload,
EspoCRMWebhookPayload,
)

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool:
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
return False

# TODO: security-hardening: move webhook auth to per-webhook generated secrets
# sourced from an admin dashboard with copyable callback URLs.
provided_secret = request.headers.get("X-API-Secret", "")
return secrets.compare_digest(provided_secret, settings.api_shared_secret)
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
return True

return False


def _extract_idempotency_key(value: object) -> str | None:
Expand DownExpand Up@@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
)


async def docuseal_webhook_handler(request: Request) -> JSONResponse:
"""Process a Docuseal form.completed webhook and enqueue agreement job."""
if not _is_authorized(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)

try:
payload_data = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)

if not isinstance(payload_data, dict):
return JSONResponse({"error": "payload_must_be_object"}, status_code=400)

try:
payload = DocusealWebhookPayload.model_validate(payload_data)
except (ValidationError, TypeError) as exc:
return JSONResponse(
{"error": "invalid_payload", "detail": str(exc)},
status_code=400,
)

if payload.event_type != "form.completed":
return JSONResponse(
{
"status": "ignored",
"reason": f"unhandled event_type: {payload.event_type}",
},
status_code=200,
)

submitter = payload.data
submission_id = (
submitter.submission_id if submitter.submission_id is not None else submitter.id
)

template_filter_id = settings.docuseal_member_agreement_template_id
if template_filter_id is None:
logger.info("Ignoring Docuseal agreement webhook: template filter is unset")
return JSONResponse(
{
"status": "ignored",
"reason": "template_filter_not_configured",
},
status_code=200,
)

template_id = submitter.template.id if submitter.template else None
if template_id != template_filter_id:
logger.info(
"Ignoring Docuseal agreement webhook for unmatched template_id=%s"
" expected=%s submission_id=%s",
template_id,
template_filter_id,
submission_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "template_mismatch",
"submission_id": submission_id,
},
status_code=200,
)

email = (submitter.email or "").strip()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
completed_at = submitter.completed_at or payload.timestamp
if isinstance(completed_at, str):
completed_at = completed_at.strip()
if not isinstance(completed_at, str) or not completed_at:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

try:
datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

if not email:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

masked_email = mask_email(email)

queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(email, completed_at, submission_id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submission_id}",
)
except Exception:
logger.exception(
"Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return JSONResponse({"error": "enqueue_failed"}, status_code=503)

logger.info(
"Enqueued Docuseal agreement job job_id=%s masked_email=%s",
job.id,
masked_email,
)
return JSONResponse(
{
"status": "queued",
"source": "docuseal",
"job_id": job.id,
"masked_email": masked_email,
"submission_id": submission_id,
},
status_code=202,
)


async def audit_event_handler(request: Request) -> JSONResponse:
"""Persist one human audit event."""
if not _is_authorized(request):
Expand DownExpand Up@@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
espocrm_people_sync_webhook_handler,
methods=["POST"],
)
app.add_api_route(
"/webhooks/docuseal",
docuseal_webhook_handler,
methods=["POST"],
)
app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"])

app.add_api_route(
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/five08/worker/actors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
Expand All@@ -48,6 +49,7 @@
apply_resume_profile_job.__name__: apply_resume_profile_job,
sync_people_from_crm_job.__name__: sync_people_from_crm_job,
sync_person_from_crm_job.__name__: sync_person_from_crm_job,
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
}


Expand Down
20 changes: 19 additions & 1 deletion apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import field_validator, model_validator

from five08.settings import SharedSettings

Expand All@@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings):
openai_model: str = "gpt-4o-mini"
resume_ai_model: str = "gpt-4o-mini"
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

max_file_size_mb: int = 10
allowed_file_types: str = "pdf,doc,docx,txt"
Expand DownExpand Up@@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings":
self.auth_cookie_samesite = normalized
return self

@field_validator("docuseal_member_agreement_template_id", mode="before")
@classmethod
def _normalize_docuseal_member_agreement_template_id(
cls,
value: object,
) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return None
return int(normalized)
raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer")

@property
def allowed_file_extensions(self) -> set[str]:
"""Allowed resume file extensions."""
Expand Down
98 changes: 98 additions & 0 deletions apps/worker/src/five08/worker/crm/docuseal_processor.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Docuseal member agreement processing workflow."""

import logging
from typing import Any

from five08.clients.espo import EspoAPI, EspoAPIError
from five08.worker.config import settings
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)


class DocusealAgreementProcessor:
"""Look up a CRM contact by email and mark their member agreement as signed."""

def __init__(self) -> None:
api_url = settings.espo_base_url.rstrip("/") + "/api/v1"
self.api = EspoAPI(api_url, settings.espo_api_key)

def process_agreement(
self,
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Search for the signer by email and update cMemberAgreementSignedAt."""
masked_email = mask_email(email)

try:
result = self.api.request(
"GET",
"Contact",
{
"where": [
{
"type": "equals",
"attribute": "emailAddress",
"value": email,
}
],
"maxSize": 1,
"select": "id,name,emailAddress",
},
)
except EspoAPIError as exc:
logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc)
return {
"success": False,
"masked_email": masked_email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning(
"No CRM contact found for masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return {
"success": False,
"masked_email": masked_email,
"error": "contact_not_found",
}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{
"cMemberAgreementSignedAt": completed_at,
},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"masked_email": masked_email,
"submission_id": submission_id,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s masked_email=%s",
contact_id,
masked_email,
)
return {
"success": True,
"masked_email": masked_email,
"contact_id": contact_id,
"submission_id": submission_id,
"completed_at": completed_at,
}
17 changes: 17 additions & 0 deletions apps/worker/src/five08/worker/jobs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@
from datetime import datetime, timezone
from typing import Any

from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor
from five08.worker.crm.people_sync import PeopleSyncProcessor
from five08.worker.crm.processor import ContactSkillsProcessor
from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -68,6 +70,21 @@ def apply_resume_profile_job(
return result.model_dump()


def process_docuseal_agreement_job(
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Mark a CRM contact as having signed the member agreement via Docuseal."""
logger.info(
"Processing Docuseal agreement job masked_email=%s submission_id=%s",
mask_email(email),
submission_id,
)
processor = DocusealAgreementProcessor()
return processor.process_agreement(email, completed_at, submission_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_people_from_crm_job() -> dict[str, Any]:
"""Sync a full contacts page-set from CRM into the local people cache."""
logger.info("Processing CRM people full-sync job")
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/five08/worker/masking.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
"""PII masking helpers used across worker modules."""


def mask_email(email: str) -> str:
"""Return a deterministic redacted email representation for logs and responses."""
local, at, domain = email.partition("@")
if not at:
return "***"

masked_local = (local[:1] if local else "*") + "***"

if not domain:
return f"{masked_local}@****..."

return f"{masked_local}@{domain[:1]}****..."
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use hash-based masking instead of partial-character exposure.

This currently reveals email initials (local[:1], domain[:1]), which weakens PII redaction and does not match a “masked email hash” requirement.

🔧 Proposed fix
+import hashlib+
def mask_email(email: str) -> str:
- """Return a deterministic redacted email representation for logs and responses."""- local, at, domain = email.partition("@")- if not at:- return "***"-- masked_local = (local[:1] if local else "*") + "***"-- if not domain:- return f"{masked_local}@****..."-- return f"{masked_local}@{domain[:1]}****..."+ """Return deterministic non-reversible email token for logs/responses."""+ normalized = email.strip().lower()+ if "@" not in normalized:+ return "email#invalid"+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]+ return f"email#{digest}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/masking.py` around lines 10 - 15, The code
currently exposes initials via masked_local = (local[:1] if local else "*") +
"***" and domain[:1], so replace character-based masking with a deterministic
hash-based mask: compute a stable hash (e.g., hashlib.sha256) of the
identifiable input (either the whole email or local+domain) and use a short
prefix of the hex digest (e.g., first 8 chars) as the visible token instead of
local[:1]/domain[:1]; update the return expressions that currently use
masked_local and domain[:1] to use the hash prefix (for example return
f"{hash_prefix}@****..." or split into hash_local and hash_domain prefixes if
you need both parts), and ensure the hashing is consistent (use .encode() on the
string and hex digest) so tests relying on deterministic masked values pass.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
CRM_SYNC_PAGE_SIZE=200
# Optional: restrict Docuseal agreements to this template id.
# If unset, Docuseal agreement processing is ignored.
DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID=

# Discord bot (required for bot runtime)
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
137 changes: 135 additions & 2 deletions apps/worker/src/five08/backend/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,22 @@
from five08.worker.config import settings
from five08.worker.db_migrations import run_job_migrations
from five08.worker.dispatcher import build_queue_client
from five08.worker.masking import mask_email
from five08.worker.jobs import (
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
)
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload
from five08.worker.models import (
AuditEventPayload,
DocusealWebhookPayload,
EspoCRMWebhookPayload,
)

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool:
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
return False

# TODO: security-hardening: move webhook auth to per-webhook generated secrets
# sourced from an admin dashboard with copyable callback URLs.
provided_secret = request.headers.get("X-API-Secret", "")
return secrets.compare_digest(provided_secret, settings.api_shared_secret)
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
return True

return False


def _extract_idempotency_key(value: object) -> str | None:
Expand DownExpand Up@@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
)


async def docuseal_webhook_handler(request: Request) -> JSONResponse:
"""Process a Docuseal form.completed webhook and enqueue agreement job."""
if not _is_authorized(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)

try:
payload_data = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)

if not isinstance(payload_data, dict):
return JSONResponse({"error": "payload_must_be_object"}, status_code=400)

try:
payload = DocusealWebhookPayload.model_validate(payload_data)
except (ValidationError, TypeError) as exc:
return JSONResponse(
{"error": "invalid_payload", "detail": str(exc)},
status_code=400,
)

if payload.event_type != "form.completed":
return JSONResponse(
{
"status": "ignored",
"reason": f"unhandled event_type: {payload.event_type}",
},
status_code=200,
)

submitter = payload.data
submission_id = (
submitter.submission_id if submitter.submission_id is not None else submitter.id
)

template_filter_id = settings.docuseal_member_agreement_template_id
if template_filter_id is None:
logger.info("Ignoring Docuseal agreement webhook: template filter is unset")
return JSONResponse(
{
"status": "ignored",
"reason": "template_filter_not_configured",
},
status_code=200,
)

template_id = submitter.template.id if submitter.template else None
if template_id != template_filter_id:
logger.info(
"Ignoring Docuseal agreement webhook for unmatched template_id=%s"
" expected=%s submission_id=%s",
template_id,
template_filter_id,
submission_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "template_mismatch",
"submission_id": submission_id,
},
status_code=200,
)

email = (submitter.email or "").strip()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
completed_at = submitter.completed_at or payload.timestamp
if isinstance(completed_at, str):
completed_at = completed_at.strip()
if not isinstance(completed_at, str) or not completed_at:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

try:
datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

if not email:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

masked_email = mask_email(email)

queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(email, completed_at, submission_id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submission_id}",
)
except Exception:
logger.exception(
"Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return JSONResponse({"error": "enqueue_failed"}, status_code=503)

logger.info(
"Enqueued Docuseal agreement job job_id=%s masked_email=%s",
job.id,
masked_email,
)
return JSONResponse(
{
"status": "queued",
"source": "docuseal",
"job_id": job.id,
"masked_email": masked_email,
"submission_id": submission_id,
},
status_code=202,
)


async def audit_event_handler(request: Request) -> JSONResponse:
"""Persist one human audit event."""
if not _is_authorized(request):
Expand DownExpand Up@@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
espocrm_people_sync_webhook_handler,
methods=["POST"],
)
app.add_api_route(
"/webhooks/docuseal",
docuseal_webhook_handler,
methods=["POST"],
)
app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"])

app.add_api_route(
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/five08/worker/actors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
Expand All@@ -48,6 +49,7 @@
apply_resume_profile_job.__name__: apply_resume_profile_job,
sync_people_from_crm_job.__name__: sync_people_from_crm_job,
sync_person_from_crm_job.__name__: sync_person_from_crm_job,
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
}


Expand Down
20 changes: 19 additions & 1 deletion apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import field_validator, model_validator

from five08.settings import SharedSettings

Expand All@@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings):
openai_model: str = "gpt-4o-mini"
resume_ai_model: str = "gpt-4o-mini"
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

max_file_size_mb: int = 10
allowed_file_types: str = "pdf,doc,docx,txt"
Expand DownExpand Up@@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings":
self.auth_cookie_samesite = normalized
return self

@field_validator("docuseal_member_agreement_template_id", mode="before")
@classmethod
def _normalize_docuseal_member_agreement_template_id(
cls,
value: object,
) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return None
return int(normalized)
raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer")

@property
def allowed_file_extensions(self) -> set[str]:
"""Allowed resume file extensions."""
Expand Down
98 changes: 98 additions & 0 deletions apps/worker/src/five08/worker/crm/docuseal_processor.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Docuseal member agreement processing workflow."""

import logging
from typing import Any

from five08.clients.espo import EspoAPI, EspoAPIError
from five08.worker.config import settings
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)


class DocusealAgreementProcessor:
"""Look up a CRM contact by email and mark their member agreement as signed."""

def __init__(self) -> None:
api_url = settings.espo_base_url.rstrip("/") + "/api/v1"
self.api = EspoAPI(api_url, settings.espo_api_key)

def process_agreement(
self,
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Search for the signer by email and update cMemberAgreementSignedAt."""
masked_email = mask_email(email)

try:
result = self.api.request(
"GET",
"Contact",
{
"where": [
{
"type": "equals",
"attribute": "emailAddress",
"value": email,
}
],
"maxSize": 1,
"select": "id,name,emailAddress",
},
)
except EspoAPIError as exc:
logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc)
return {
"success": False,
"masked_email": masked_email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning(
"No CRM contact found for masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return {
"success": False,
"masked_email": masked_email,
"error": "contact_not_found",
}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{
"cMemberAgreementSignedAt": completed_at,
},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"masked_email": masked_email,
"submission_id": submission_id,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s masked_email=%s",
contact_id,
masked_email,
)
return {
"success": True,
"masked_email": masked_email,
"contact_id": contact_id,
"submission_id": submission_id,
"completed_at": completed_at,
}
17 changes: 17 additions & 0 deletions apps/worker/src/five08/worker/jobs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@
from datetime import datetime, timezone
from typing import Any

from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor
from five08.worker.crm.people_sync import PeopleSyncProcessor
from five08.worker.crm.processor import ContactSkillsProcessor
from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -68,6 +70,21 @@ def apply_resume_profile_job(
return result.model_dump()


def process_docuseal_agreement_job(
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Mark a CRM contact as having signed the member agreement via Docuseal."""
logger.info(
"Processing Docuseal agreement job masked_email=%s submission_id=%s",
mask_email(email),
submission_id,
)
processor = DocusealAgreementProcessor()
return processor.process_agreement(email, completed_at, submission_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_people_from_crm_job() -> dict[str, Any]:
"""Sync a full contacts page-set from CRM into the local people cache."""
logger.info("Processing CRM people full-sync job")
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/five08/worker/masking.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
"""PII masking helpers used across worker modules."""


def mask_email(email: str) -> str:
"""Return a deterministic redacted email representation for logs and responses."""
local, at, domain = email.partition("@")
if not at:
return "***"

masked_local = (local[:1] if local else "*") + "***"

if not domain:
return f"{masked_local}@****..."

return f"{masked_local}@{domain[:1]}****..."
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use hash-based masking instead of partial-character exposure.

This currently reveals email initials (local[:1], domain[:1]), which weakens PII redaction and does not match a “masked email hash” requirement.

🔧 Proposed fix
+import hashlib+
def mask_email(email: str) -> str:
- """Return a deterministic redacted email representation for logs and responses."""- local, at, domain = email.partition("@")- if not at:- return "***"-- masked_local = (local[:1] if local else "*") + "***"-- if not domain:- return f"{masked_local}@****..."-- return f"{masked_local}@{domain[:1]}****..."+ """Return deterministic non-reversible email token for logs/responses."""+ normalized = email.strip().lower()+ if "@" not in normalized:+ return "email#invalid"+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]+ return f"email#{digest}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/masking.py` around lines 10 - 15, The code
currently exposes initials via masked_local = (local[:1] if local else "*") +
"***" and domain[:1], so replace character-based masking with a deterministic
hash-based mask: compute a stable hash (e.g., hashlib.sha256) of the
identifiable input (either the whole email or local+domain) and use a short
prefix of the hex digest (e.g., first 8 chars) as the visible token instead of
local[:1]/domain[:1]; update the return expressions that currently use
masked_local and domain[:1] to use the hash prefix (for example return
f"{hash_prefix}@****..." or split into hash_local and hash_domain prefixes if
you need both parts), and ensure the hashing is consistent (use .encode() on the
string and hex digest) so tests relying on deterministic masked values pass.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
CRM_SYNC_PAGE_SIZE=200
# Optional: restrict Docuseal agreements to this template id.
# If unset, Docuseal agreement processing is ignored.
DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID=

# Discord bot (required for bot runtime)
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
137 changes: 135 additions & 2 deletions apps/worker/src/five08/backend/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,22 @@
from five08.worker.config import settings
from five08.worker.db_migrations import run_job_migrations
from five08.worker.dispatcher import build_queue_client
from five08.worker.masking import mask_email
from five08.worker.jobs import (
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
)
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload
from five08.worker.models import (
AuditEventPayload,
DocusealWebhookPayload,
EspoCRMWebhookPayload,
)

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool:
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
return False

# TODO: security-hardening: move webhook auth to per-webhook generated secrets
# sourced from an admin dashboard with copyable callback URLs.
provided_secret = request.headers.get("X-API-Secret", "")
return secrets.compare_digest(provided_secret, settings.api_shared_secret)
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
return True

return False


def _extract_idempotency_key(value: object) -> str | None:
Expand DownExpand Up@@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
)


async def docuseal_webhook_handler(request: Request) -> JSONResponse:
"""Process a Docuseal form.completed webhook and enqueue agreement job."""
if not _is_authorized(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)

try:
payload_data = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)

if not isinstance(payload_data, dict):
return JSONResponse({"error": "payload_must_be_object"}, status_code=400)

try:
payload = DocusealWebhookPayload.model_validate(payload_data)
except (ValidationError, TypeError) as exc:
return JSONResponse(
{"error": "invalid_payload", "detail": str(exc)},
status_code=400,
)

if payload.event_type != "form.completed":
return JSONResponse(
{
"status": "ignored",
"reason": f"unhandled event_type: {payload.event_type}",
},
status_code=200,
)

submitter = payload.data
submission_id = (
submitter.submission_id if submitter.submission_id is not None else submitter.id
)

template_filter_id = settings.docuseal_member_agreement_template_id
if template_filter_id is None:
logger.info("Ignoring Docuseal agreement webhook: template filter is unset")
return JSONResponse(
{
"status": "ignored",
"reason": "template_filter_not_configured",
},
status_code=200,
)

template_id = submitter.template.id if submitter.template else None
if template_id != template_filter_id:
logger.info(
"Ignoring Docuseal agreement webhook for unmatched template_id=%s"
" expected=%s submission_id=%s",
template_id,
template_filter_id,
submission_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "template_mismatch",
"submission_id": submission_id,
},
status_code=200,
)

email = (submitter.email or "").strip()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
completed_at = submitter.completed_at or payload.timestamp
if isinstance(completed_at, str):
completed_at = completed_at.strip()
if not isinstance(completed_at, str) or not completed_at:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

try:
datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

if not email:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

masked_email = mask_email(email)

queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(email, completed_at, submission_id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submission_id}",
)
except Exception:
logger.exception(
"Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return JSONResponse({"error": "enqueue_failed"}, status_code=503)

logger.info(
"Enqueued Docuseal agreement job job_id=%s masked_email=%s",
job.id,
masked_email,
)
return JSONResponse(
{
"status": "queued",
"source": "docuseal",
"job_id": job.id,
"masked_email": masked_email,
"submission_id": submission_id,
},
status_code=202,
)


async def audit_event_handler(request: Request) -> JSONResponse:
"""Persist one human audit event."""
if not _is_authorized(request):
Expand DownExpand Up@@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
espocrm_people_sync_webhook_handler,
methods=["POST"],
)
app.add_api_route(
"/webhooks/docuseal",
docuseal_webhook_handler,
methods=["POST"],
)
app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"])

app.add_api_route(
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/five08/worker/actors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
Expand All@@ -48,6 +49,7 @@
apply_resume_profile_job.__name__: apply_resume_profile_job,
sync_people_from_crm_job.__name__: sync_people_from_crm_job,
sync_person_from_crm_job.__name__: sync_person_from_crm_job,
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
}


Expand Down
20 changes: 19 additions & 1 deletion apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import field_validator, model_validator

from five08.settings import SharedSettings

Expand All@@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings):
openai_model: str = "gpt-4o-mini"
resume_ai_model: str = "gpt-4o-mini"
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

max_file_size_mb: int = 10
allowed_file_types: str = "pdf,doc,docx,txt"
Expand DownExpand Up@@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings":
self.auth_cookie_samesite = normalized
return self

@field_validator("docuseal_member_agreement_template_id", mode="before")
@classmethod
def _normalize_docuseal_member_agreement_template_id(
cls,
value: object,
) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return None
return int(normalized)
raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer")

@property
def allowed_file_extensions(self) -> set[str]:
"""Allowed resume file extensions."""
Expand Down
98 changes: 98 additions & 0 deletions apps/worker/src/five08/worker/crm/docuseal_processor.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Docuseal member agreement processing workflow."""

import logging
from typing import Any

from five08.clients.espo import EspoAPI, EspoAPIError
from five08.worker.config import settings
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)


class DocusealAgreementProcessor:
"""Look up a CRM contact by email and mark their member agreement as signed."""

def __init__(self) -> None:
api_url = settings.espo_base_url.rstrip("/") + "/api/v1"
self.api = EspoAPI(api_url, settings.espo_api_key)

def process_agreement(
self,
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Search for the signer by email and update cMemberAgreementSignedAt."""
masked_email = mask_email(email)

try:
result = self.api.request(
"GET",
"Contact",
{
"where": [
{
"type": "equals",
"attribute": "emailAddress",
"value": email,
}
],
"maxSize": 1,
"select": "id,name,emailAddress",
},
)
except EspoAPIError as exc:
logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc)
return {
"success": False,
"masked_email": masked_email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning(
"No CRM contact found for masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return {
"success": False,
"masked_email": masked_email,
"error": "contact_not_found",
}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{
"cMemberAgreementSignedAt": completed_at,
},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"masked_email": masked_email,
"submission_id": submission_id,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s masked_email=%s",
contact_id,
masked_email,
)
return {
"success": True,
"masked_email": masked_email,
"contact_id": contact_id,
"submission_id": submission_id,
"completed_at": completed_at,
}
17 changes: 17 additions & 0 deletions apps/worker/src/five08/worker/jobs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@
from datetime import datetime, timezone
from typing import Any

from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor
from five08.worker.crm.people_sync import PeopleSyncProcessor
from five08.worker.crm.processor import ContactSkillsProcessor
from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -68,6 +70,21 @@ def apply_resume_profile_job(
return result.model_dump()


def process_docuseal_agreement_job(
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Mark a CRM contact as having signed the member agreement via Docuseal."""
logger.info(
"Processing Docuseal agreement job masked_email=%s submission_id=%s",
mask_email(email),
submission_id,
)
processor = DocusealAgreementProcessor()
return processor.process_agreement(email, completed_at, submission_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_people_from_crm_job() -> dict[str, Any]:
"""Sync a full contacts page-set from CRM into the local people cache."""
logger.info("Processing CRM people full-sync job")
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/five08/worker/masking.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
"""PII masking helpers used across worker modules."""


def mask_email(email: str) -> str:
"""Return a deterministic redacted email representation for logs and responses."""
local, at, domain = email.partition("@")
if not at:
return "***"

masked_local = (local[:1] if local else "*") + "***"

if not domain:
return f"{masked_local}@****..."

return f"{masked_local}@{domain[:1]}****..."
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use hash-based masking instead of partial-character exposure.

This currently reveals email initials (local[:1], domain[:1]), which weakens PII redaction and does not match a “masked email hash” requirement.

🔧 Proposed fix
+import hashlib+
def mask_email(email: str) -> str:
- """Return a deterministic redacted email representation for logs and responses."""- local, at, domain = email.partition("@")- if not at:- return "***"-- masked_local = (local[:1] if local else "*") + "***"-- if not domain:- return f"{masked_local}@****..."-- return f"{masked_local}@{domain[:1]}****..."+ """Return deterministic non-reversible email token for logs/responses."""+ normalized = email.strip().lower()+ if "@" not in normalized:+ return "email#invalid"+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]+ return f"email#{digest}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/masking.py` around lines 10 - 15, The code
currently exposes initials via masked_local = (local[:1] if local else "*") +
"***" and domain[:1], so replace character-based masking with a deterministic
hash-based mask: compute a stable hash (e.g., hashlib.sha256) of the
identifiable input (either the whole email or local+domain) and use a short
prefix of the hex digest (e.g., first 8 chars) as the visible token instead of
local[:1]/domain[:1]; update the return expressions that currently use
masked_local and domain[:1] to use the hash prefix (for example return
f"{hash_prefix}@****..." or split into hash_local and hash_domain prefixes if
you need both parts), and ensure the hashing is consistent (use .encode() on the
string and hex digest) so tests relying on deterministic masked values pass.

Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1
CRM_SYNC_ENABLED=true
CRM_SYNC_INTERVAL_SECONDS=900
CRM_SYNC_PAGE_SIZE=200
# Optional: restrict Docuseal agreements to this template id.
# If unset, Docuseal agreement processing is ignored.
DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID=

# Discord bot (required for bot runtime)
DISCORD_BOT_TOKEN=your_bot_token_here
Expand Down
137 changes: 135 additions & 2 deletions apps/worker/src/five08/backend/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,16 +54,22 @@
from five08.worker.config import settings
from five08.worker.db_migrations import run_job_migrations
from five08.worker.dispatcher import build_queue_client
from five08.worker.masking import mask_email
from five08.worker.jobs import (
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
)
from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor
from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload
from five08.worker.models import (
AuditEventPayload,
DocusealWebhookPayload,
EspoCRMWebhookPayload,
)

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool:
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
return False

# TODO: security-hardening: move webhook auth to per-webhook generated secrets
# sourced from an admin dashboard with copyable callback URLs.
provided_secret = request.headers.get("X-API-Secret", "")
return secrets.compare_digest(provided_secret, settings.api_shared_secret)
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
return True

return False


def _extract_idempotency_key(value: object) -> str | None:
Expand DownExpand Up@@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
)


async def docuseal_webhook_handler(request: Request) -> JSONResponse:
"""Process a Docuseal form.completed webhook and enqueue agreement job."""
if not _is_authorized(request):
return JSONResponse({"error": "unauthorized"}, status_code=401)

try:
payload_data = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)

if not isinstance(payload_data, dict):
return JSONResponse({"error": "payload_must_be_object"}, status_code=400)

try:
payload = DocusealWebhookPayload.model_validate(payload_data)
except (ValidationError, TypeError) as exc:
return JSONResponse(
{"error": "invalid_payload", "detail": str(exc)},
status_code=400,
)

if payload.event_type != "form.completed":
return JSONResponse(
{
"status": "ignored",
"reason": f"unhandled event_type: {payload.event_type}",
},
status_code=200,
)

submitter = payload.data
submission_id = (
submitter.submission_id if submitter.submission_id is not None else submitter.id
)

template_filter_id = settings.docuseal_member_agreement_template_id
if template_filter_id is None:
logger.info("Ignoring Docuseal agreement webhook: template filter is unset")
return JSONResponse(
{
"status": "ignored",
"reason": "template_filter_not_configured",
},
status_code=200,
)

template_id = submitter.template.id if submitter.template else None
if template_id != template_filter_id:
logger.info(
"Ignoring Docuseal agreement webhook for unmatched template_id=%s"
" expected=%s submission_id=%s",
template_id,
template_filter_id,
submission_id,
)
return JSONResponse(
{
"status": "ignored",
"reason": "template_mismatch",
"submission_id": submission_id,
},
status_code=200,
)

email = (submitter.email or "").strip()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
completed_at = submitter.completed_at or payload.timestamp
if isinstance(completed_at, str):
completed_at = completed_at.strip()
if not isinstance(completed_at, str) or not completed_at:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

try:
datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
except ValueError:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

if not email:
return JSONResponse({"error": "invalid_payload"}, status_code=400)

masked_email = mask_email(email)

queue = request.app.state.queue
try:
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=process_docuseal_agreement_job,
args=(email, completed_at, submission_id),
settings=settings,
idempotency_key=f"docuseal-agreement:{submission_id}",
)
except Exception:
logger.exception(
"Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return JSONResponse({"error": "enqueue_failed"}, status_code=503)

logger.info(
"Enqueued Docuseal agreement job job_id=%s masked_email=%s",
job.id,
masked_email,
)
return JSONResponse(
{
"status": "queued",
"source": "docuseal",
"job_id": job.id,
"masked_email": masked_email,
"submission_id": submission_id,
},
status_code=202,
)


async def audit_event_handler(request: Request) -> JSONResponse:
"""Persist one human audit event."""
if not _is_authorized(request):
Expand DownExpand Up@@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
espocrm_people_sync_webhook_handler,
methods=["POST"],
)
app.add_api_route(
"/webhooks/docuseal",
docuseal_webhook_handler,
methods=["POST"],
)
app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"])

app.add_api_route(
Expand Down
2 changes: 2 additions & 0 deletions apps/worker/src/five08/worker/actors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@
apply_resume_profile_job,
extract_resume_profile_job,
process_contact_skills_job,
process_docuseal_agreement_job,
process_webhook_event,
sync_people_from_crm_job,
sync_person_from_crm_job,
Expand All@@ -48,6 +49,7 @@
apply_resume_profile_job.__name__: apply_resume_profile_job,
sync_people_from_crm_job.__name__: sync_people_from_crm_job,
sync_person_from_crm_job.__name__: sync_person_from_crm_job,
process_docuseal_agreement_job.__name__: process_docuseal_agreement_job,
}


Expand Down
20 changes: 19 additions & 1 deletion apps/worker/src/five08/worker/config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from urllib.parse import urlparse

from pydantic import model_validator
from pydantic import field_validator, model_validator

from five08.settings import SharedSettings

Expand All@@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings):
openai_model: str = "gpt-4o-mini"
resume_ai_model: str = "gpt-4o-mini"
resume_extractor_version: str = "v1"
docuseal_member_agreement_template_id: int | None = None

max_file_size_mb: int = 10
allowed_file_types: str = "pdf,doc,docx,txt"
Expand DownExpand Up@@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings":
self.auth_cookie_samesite = normalized
return self

@field_validator("docuseal_member_agreement_template_id", mode="before")
@classmethod
def _normalize_docuseal_member_agreement_template_id(
cls,
value: object,
) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return None
return int(normalized)
raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer")

@property
def allowed_file_extensions(self) -> set[str]:
"""Allowed resume file extensions."""
Expand Down
98 changes: 98 additions & 0 deletions apps/worker/src/five08/worker/crm/docuseal_processor.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Docuseal member agreement processing workflow."""

import logging
from typing import Any

from five08.clients.espo import EspoAPI, EspoAPIError
from five08.worker.config import settings
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)


class DocusealAgreementProcessor:
"""Look up a CRM contact by email and mark their member agreement as signed."""

def __init__(self) -> None:
api_url = settings.espo_base_url.rstrip("/") + "/api/v1"
self.api = EspoAPI(api_url, settings.espo_api_key)

def process_agreement(
self,
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Search for the signer by email and update cMemberAgreementSignedAt."""
masked_email = mask_email(email)

try:
result = self.api.request(
"GET",
"Contact",
{
"where": [
{
"type": "equals",
"attribute": "emailAddress",
"value": email,
}
],
"maxSize": 1,
"select": "id,name,emailAddress",
},
)
except EspoAPIError as exc:
logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc)
return {
"success": False,
"masked_email": masked_email,
"error": f"CRM search failed: {exc}",
}

contacts = result.get("list", [])
if not contacts:
logger.warning(
"No CRM contact found for masked_email=%s submission_id=%s",
masked_email,
submission_id,
)
return {
"success": False,
"masked_email": masked_email,
"error": "contact_not_found",
}

contact = contacts[0]
contact_id = contact["id"]

try:
self.api.request(
"PUT",
f"Contact/{contact_id}",
{
"cMemberAgreementSignedAt": completed_at,
},
)
except EspoAPIError as exc:
logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc)
return {
"success": False,
"masked_email": masked_email,
"submission_id": submission_id,
"contact_id": contact_id,
"error": f"CRM update failed: {exc}",
}

logger.info(
"Marked member agreement signed contact_id=%s masked_email=%s",
contact_id,
masked_email,
)
return {
"success": True,
"masked_email": masked_email,
"contact_id": contact_id,
"submission_id": submission_id,
"completed_at": completed_at,
}
17 changes: 17 additions & 0 deletions apps/worker/src/five08/worker/jobs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,9 +4,11 @@
from datetime import datetime, timezone
from typing import Any

from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor
from five08.worker.crm.people_sync import PeopleSyncProcessor
from five08.worker.crm.processor import ContactSkillsProcessor
from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor
from five08.worker.masking import mask_email

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -68,6 +70,21 @@ def apply_resume_profile_job(
return result.model_dump()


def process_docuseal_agreement_job(
email: str,
completed_at: str,
submission_id: int,
) -> dict[str, Any]:
"""Mark a CRM contact as having signed the member agreement via Docuseal."""
logger.info(
"Processing Docuseal agreement job masked_email=%s submission_id=%s",
mask_email(email),
submission_id,
)
processor = DocusealAgreementProcessor()
return processor.process_agreement(email, completed_at, submission_id)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def sync_people_from_crm_job() -> dict[str, Any]:
"""Sync a full contacts page-set from CRM into the local people cache."""
logger.info("Processing CRM people full-sync job")
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/five08/worker/masking.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
"""PII masking helpers used across worker modules."""


def mask_email(email: str) -> str:
"""Return a deterministic redacted email representation for logs and responses."""
local, at, domain = email.partition("@")
if not at:
return "***"

masked_local = (local[:1] if local else "*") + "***"

if not domain:
return f"{masked_local}@****..."

return f"{masked_local}@{domain[:1]}****..."
Comment on lines +10 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use hash-based masking instead of partial-character exposure.

This currently reveals email initials (local[:1], domain[:1]), which weakens PII redaction and does not match a “masked email hash” requirement.

🔧 Proposed fix
+import hashlib+
def mask_email(email: str) -> str:
- """Return a deterministic redacted email representation for logs and responses."""- local, at, domain = email.partition("@")- if not at:- return "***"-- masked_local = (local[:1] if local else "*") + "***"-- if not domain:- return f"{masked_local}@****..."-- return f"{masked_local}@{domain[:1]}****..."+ """Return deterministic non-reversible email token for logs/responses."""+ normalized = email.strip().lower()+ if "@" not in normalized:+ return "email#invalid"+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]+ return f"email#{digest}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/worker/src/five08/worker/masking.py` around lines 10 - 15, The code
currently exposes initials via masked_local = (local[:1] if local else "*") +
"***" and domain[:1], so replace character-based masking with a deterministic
hash-based mask: compute a stable hash (e.g., hashlib.sha256) of the
identifiable input (either the whole email or local+domain) and use a short
prefix of the hex digest (e.g., first 8 chars) as the visible token instead of
local[:1]/domain[:1]; update the return expressions that currently use
masked_local and domain[:1] to use the hash prefix (for example return
f"{hash_prefix}@****..." or split into hash_local and hash_domain prefixes if
you need both parts), and ensure the hashing is consistent (use .encode() on the
string and hex digest) so tests relying on deterministic masked values pass.

Loading