keywright-server 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
keywright_server/mail.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Inbox cache and owner-configured mail automations; mailbox calls retain broker policy checks.
|
|
2
|
+
|
|
3
|
+
The UI worker is opt-in and independent of the legacy YAML router. Disable overlapping
|
|
4
|
+
legacy rules before enabling it. A first sync only indexes mail; existing mail is processed
|
|
5
|
+
only by an explicit Run rules action. Delivery receipts survive retries and process restarts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from email.utils import parseaddr
|
|
15
|
+
from fnmatch import fnmatchcase
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from sqlalchemy import select
|
|
20
|
+
|
|
21
|
+
from . import audit, settings_store
|
|
22
|
+
from .db import session
|
|
23
|
+
from .models import AccessRequest, Agent, MailMessage, MailRule, Resource, now
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
MAX_PDF = 5 * 1024 * 1024 # upstream's 8 MiB serialized result must also fit base64
|
|
27
|
+
STATUS = {"unprocessed": "Unprocessed", "queued": "Queued", "running": "Processing",
|
|
28
|
+
"pending": "Waiting for approval", "done": "Completed", "failed": "Failed"}
|
|
29
|
+
lock = asyncio.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MailError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Pending(MailError):
|
|
37
|
+
def __init__(self, request_id):
|
|
38
|
+
self.request_id = request_id
|
|
39
|
+
super().__init__("Waiting for Keywright approval")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def source(cfg):
|
|
43
|
+
return hashlib.sha256(json.dumps([cfg.get(k) for k in ("resource", "account", "mailbox")]).encode()).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def budget_url(value):
|
|
47
|
+
url = urlsplit(value.strip())
|
|
48
|
+
if (url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password
|
|
49
|
+
or url.query or url.fragment or url.path.rstrip('/') not in {'', '/api/integrations/documents'}):
|
|
50
|
+
raise MailError("Enter the budget server URL, without credentials, a query, or an ingress path.")
|
|
51
|
+
return f"{url.scheme}://{url.netloc}/api/integrations/documents"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def config(db):
|
|
55
|
+
cfg = await settings_store.get(db, "mail")
|
|
56
|
+
return {"account": "agents", "mailbox": "INBOX", "enabled": False, **cfg}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def payload(result):
|
|
60
|
+
data = getattr(result, "structured_content", None)
|
|
61
|
+
if isinstance(data, dict):
|
|
62
|
+
return data
|
|
63
|
+
for block in result.content:
|
|
64
|
+
if getattr(block, "type", None) == "text":
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(block.text)
|
|
67
|
+
if isinstance(data, dict):
|
|
68
|
+
return data
|
|
69
|
+
except (ValueError, TypeError):
|
|
70
|
+
pass
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def call(db, cfg, tool, args):
|
|
75
|
+
from .mcp_server import _proxy_call
|
|
76
|
+
agent = await db.get(Agent, cfg.get("agent")) if cfg.get("agent") else None
|
|
77
|
+
resource = await db.get(Resource, cfg.get("resource")) if cfg.get("resource") else None
|
|
78
|
+
if not agent or agent.disabled or not resource or resource.kind != "mcp":
|
|
79
|
+
raise MailError("Choose an enabled agent and an email resource in Mail settings.")
|
|
80
|
+
result = await _proxy_call(db, agent, "mail-inbox", resource.name, tool,
|
|
81
|
+
{**args, "_reason": "Mailbox UI and saved mail rules", "_wait_seconds": 0})
|
|
82
|
+
if result.is_error:
|
|
83
|
+
data = payload(result)
|
|
84
|
+
if data.get("error") == "approval_required" and data.get("request_id"):
|
|
85
|
+
raise Pending(data["request_id"])
|
|
86
|
+
# Do not surface upstream errors that might contain credentials or message contents.
|
|
87
|
+
raise MailError(f"Email operation {tool} failed. Check Keywright’s audit log and email service settings.")
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def mail_args(cfg):
|
|
92
|
+
return {"account_name": cfg["account"], "mailbox": cfg["mailbox"]}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def matches(rule, message):
|
|
96
|
+
return ((not rule.sender or fnmatchcase(parseaddr(message.sender)[1].lower(), rule.sender.lower()))
|
|
97
|
+
and (not rule.subject or rule.subject.lower() in message.subject.lower()))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def snapshot(rule, cfg):
|
|
101
|
+
return {"rule_id": rule.id, "name": rule.name, "action": rule.action, "target": rule.target,
|
|
102
|
+
"destination": cfg.get("budget_url", "") if rule.action == "pdf" else ""}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def rules(db):
|
|
106
|
+
return list((await db.scalars(select(MailRule).order_by(MailRule.position, MailRule.created_at))).all())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def queue(db, cfg, message):
|
|
110
|
+
if message.status in {"done", "running", "pending", "queued"}:
|
|
111
|
+
return
|
|
112
|
+
selected = next((r for r in await rules(db) if r.enabled and matches(r, message)), None)
|
|
113
|
+
if not selected:
|
|
114
|
+
message.note = "No enabled rule matched."
|
|
115
|
+
await db.commit()
|
|
116
|
+
return
|
|
117
|
+
message.plan = snapshot(selected, cfg)
|
|
118
|
+
message.status, message.note, message.request_id = "queued", "", None
|
|
119
|
+
message.updated_at = now()
|
|
120
|
+
await db.commit()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def sync(db, cfg):
|
|
124
|
+
if not cfg.get("resource"):
|
|
125
|
+
raise MailError("Connect the mailbox in Mail settings first.")
|
|
126
|
+
identity = source(cfg)
|
|
127
|
+
initial = cfg.get("indexed_source") != identity
|
|
128
|
+
existing = {m.id: m for m in (await db.scalars(select(MailMessage).where(MailMessage.source == identity))).all()}
|
|
129
|
+
seen = set()
|
|
130
|
+
active_rules = [r for r in await rules(db) if r.enabled]
|
|
131
|
+
for page in range(1, 101): # upstream caps metadata candidates at 10,000
|
|
132
|
+
data = payload(await call(db, cfg, "list_emails_metadata", {
|
|
133
|
+
**mail_args(cfg), "page": page, "page_size": 100, "order": "desc"}))
|
|
134
|
+
if not isinstance(data.get("emails"), list):
|
|
135
|
+
raise MailError("Email service returned an invalid message list.")
|
|
136
|
+
for meta in data["emails"]:
|
|
137
|
+
uid = str(meta["email_id"])
|
|
138
|
+
# Include immutable headers as well as Message-ID: some senders omit/reuse that header.
|
|
139
|
+
fingerprint = [identity, uid, *(str(meta.get(k) or '') for k in ('message_id', 'sender', 'subject', 'date'))]
|
|
140
|
+
key = hashlib.sha256(json.dumps(fingerprint).encode()).hexdigest()
|
|
141
|
+
seen.add(key)
|
|
142
|
+
message = existing.get(key)
|
|
143
|
+
if not message:
|
|
144
|
+
message = MailMessage(id=key, source=identity, uid=uid)
|
|
145
|
+
db.add(message)
|
|
146
|
+
existing[key] = message
|
|
147
|
+
message.message_id = str(meta.get("message_id") or "")
|
|
148
|
+
for field in ("sender", "subject", "date"):
|
|
149
|
+
setattr(message, field, str(meta.get(field) or "")[:4000])
|
|
150
|
+
message.recipients = list(meta.get("recipients") or [])[:100]
|
|
151
|
+
message.attachments = list(meta.get("attachments") or [])[:20]
|
|
152
|
+
message.present = True
|
|
153
|
+
if message in db.new and not initial and cfg.get("enabled"):
|
|
154
|
+
rule = next((r for r in active_rules if matches(r, message)), None)
|
|
155
|
+
if rule:
|
|
156
|
+
message.status, message.plan = "queued", snapshot(rule, cfg)
|
|
157
|
+
await db.commit() # message discovery and its initial job are durable together
|
|
158
|
+
if len(data["emails"]) < 100 or page * 100 >= int(data.get("total") or 0):
|
|
159
|
+
break
|
|
160
|
+
else:
|
|
161
|
+
raise MailError("Mailbox exceeds the 10,000-message sync limit; move older mail to another folder.")
|
|
162
|
+
for key, message in existing.items():
|
|
163
|
+
if key not in seen:
|
|
164
|
+
message.present = False
|
|
165
|
+
await db.commit()
|
|
166
|
+
# Re-read configuration so a concurrent settings save cannot be overwritten.
|
|
167
|
+
latest = await config(db)
|
|
168
|
+
if source(latest) == identity:
|
|
169
|
+
await settings_store.put(db, "mail", {**latest, "indexed_source": identity,
|
|
170
|
+
"last_sync": now().isoformat(), "sync_error": ""})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def body(db, cfg, message):
|
|
174
|
+
if message.body is not None:
|
|
175
|
+
return
|
|
176
|
+
data = payload(await call(db, cfg, "get_emails_content", {
|
|
177
|
+
**mail_args(cfg), "email_ids": [message.uid], "mark_as_read": False, "max_body_length": 50000}))
|
|
178
|
+
entries = data.get("emails") or []
|
|
179
|
+
if not entries or (str(entries[0].get("message_id") or "") != message.message_id):
|
|
180
|
+
raise MailError("Message is no longer available at this mailbox location. Refresh the inbox.")
|
|
181
|
+
message.body = str(entries[0].get("body") or "")
|
|
182
|
+
await db.commit()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def attachment(db, cfg, message, name):
|
|
186
|
+
if name not in message.attachments or not name.lower().endswith('.pdf'):
|
|
187
|
+
raise MailError("Choose a PDF attachment from this message.")
|
|
188
|
+
result = await call(db, cfg, "get_attachment_content", {
|
|
189
|
+
**mail_args(cfg), "email_id": message.uid, "attachment_name": name})
|
|
190
|
+
blobs = [b.resource.blob for b in result.content
|
|
191
|
+
if getattr(b, "type", None) == "resource" and hasattr(b.resource, "blob")]
|
|
192
|
+
if len(blobs) != 1 or len(blobs[0]) > (MAX_PDF + 2) // 3 * 4:
|
|
193
|
+
raise MailError("The email service did not return a PDF within the 5 MiB transfer limit.")
|
|
194
|
+
try:
|
|
195
|
+
data = base64.b64decode(blobs[0], validate=True)
|
|
196
|
+
except (ValueError, TypeError) as exc:
|
|
197
|
+
raise MailError("The email service returned invalid attachment data.") from exc
|
|
198
|
+
if len(data) > MAX_PDF or not data.startswith(b'%PDF-'):
|
|
199
|
+
raise MailError("Attachment is not a PDF or exceeds the 5 MiB transfer limit.")
|
|
200
|
+
return data
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def verify_message(db, cfg, message):
|
|
204
|
+
data = payload(await call(db, cfg, "get_emails_content", {
|
|
205
|
+
**mail_args(cfg), "email_ids": [message.uid], "mark_as_read": False, "max_body_length": 1}))
|
|
206
|
+
entries = data.get("emails") or []
|
|
207
|
+
if (not entries or str(entries[0].get("message_id") or "") != message.message_id
|
|
208
|
+
or str(entries[0].get("sender") or "")[:4000] != message.sender
|
|
209
|
+
or str(entries[0].get("subject") or "")[:4000] != message.subject):
|
|
210
|
+
raise MailError("Message identity changed or the message moved. Refresh the inbox before processing.")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def upload(cfg, message, name, content):
|
|
214
|
+
endpoint = message.plan.get("destination")
|
|
215
|
+
if not endpoint or endpoint != cfg.get("budget_url") or not cfg.get("budget_token"):
|
|
216
|
+
raise MailError("The budget destination is missing or has changed. Restore it before retrying this delivery.")
|
|
217
|
+
# URL is fixed by the owner, never taken from the email. Never follow a redirect with the token.
|
|
218
|
+
async with httpx.AsyncClient(timeout=60, follow_redirects=False) as http:
|
|
219
|
+
response = await http.post(endpoint, content=content,
|
|
220
|
+
headers={"Authorization": "Bearer " + cfg["budget_token"], "Content-Type": "application/pdf"},
|
|
221
|
+
params={"filename": name, "source_id": message.id, "sender": message.sender[:320],
|
|
222
|
+
"subject": message.subject[:500]})
|
|
223
|
+
if response.status_code not in (200, 201):
|
|
224
|
+
raise MailError(f"Budget delivery returned HTTP {response.status_code}. Check the connection and retry.")
|
|
225
|
+
try:
|
|
226
|
+
receipt = response.json()
|
|
227
|
+
if receipt["sha256"] != hashlib.sha256(content).hexdigest() or not isinstance(receipt["id"], int):
|
|
228
|
+
raise ValueError()
|
|
229
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
230
|
+
raise MailError("Budget did not confirm the PDF checksum. Retry to verify delivery.") from exc
|
|
231
|
+
return {"id": receipt["id"], "sha256": receipt["sha256"]}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def advance(db, cfg, message):
|
|
235
|
+
if message.status == "pending" and message.request_id:
|
|
236
|
+
request = await db.get(AccessRequest, message.request_id)
|
|
237
|
+
if request and request.status == "pending":
|
|
238
|
+
return
|
|
239
|
+
if not request or request.status != "approved":
|
|
240
|
+
message.status, message.note = "failed", "Access approval was denied or expired."
|
|
241
|
+
await db.commit()
|
|
242
|
+
return
|
|
243
|
+
message.status, message.request_id = "running", None
|
|
244
|
+
await db.commit()
|
|
245
|
+
try:
|
|
246
|
+
if source(cfg) != message.source:
|
|
247
|
+
raise MailError("The mailbox connection changed. Restore it before retrying this job.")
|
|
248
|
+
await verify_message(db, cfg, message)
|
|
249
|
+
action = message.plan.get("action")
|
|
250
|
+
if action == "pdf":
|
|
251
|
+
names = [a for a in message.attachments if isinstance(a, str) and a.lower().endswith('.pdf')]
|
|
252
|
+
if not names:
|
|
253
|
+
raise MailError("This message has no PDF attachment.")
|
|
254
|
+
if len(set(names)) != len(names):
|
|
255
|
+
raise MailError("Attachments have duplicate filenames; delivery needs manual review.")
|
|
256
|
+
for name in names:
|
|
257
|
+
if name in message.receipts:
|
|
258
|
+
continue
|
|
259
|
+
if len(name) > 200 or any(c in name for c in '/\\') or any(ord(c) < 32 for c in name):
|
|
260
|
+
raise MailError("The PDF filename cannot be delivered safely; download it for manual review.")
|
|
261
|
+
content = await attachment(db, cfg, message, name)
|
|
262
|
+
receipt = await upload(cfg, message, name, content)
|
|
263
|
+
message.receipts = {**message.receipts, name: receipt}
|
|
264
|
+
await audit.record(db, tool="mail_pdf_delivery", decision="allow", detail={
|
|
265
|
+
"message": message.id, "rule": message.plan.get("name"), "receipt": receipt})
|
|
266
|
+
elif action == "move" and not message.receipts.get("moved"):
|
|
267
|
+
await call(db, cfg, "move_emails", {"account_name": cfg["account"], "email_ids": [message.uid],
|
|
268
|
+
"source_mailbox": cfg["mailbox"], "destination_mailbox": message.plan["target"]})
|
|
269
|
+
message.receipts = {"moved": True}
|
|
270
|
+
message.present = False
|
|
271
|
+
elif action != "mark_read" and action != "move":
|
|
272
|
+
raise MailError("Unknown mail action.")
|
|
273
|
+
if action != "move" and not message.receipts.get("marked_read"):
|
|
274
|
+
await call(db, cfg, "mark_emails_as_read", {**mail_args(cfg), "email_ids": [message.uid]})
|
|
275
|
+
message.receipts = {**message.receipts, "marked_read": True}
|
|
276
|
+
message.status, message.note = "done", "PDFs delivered to Budget." if action == "pdf" else "Rule completed."
|
|
277
|
+
except Pending as exc:
|
|
278
|
+
message.status, message.note, message.request_id = "pending", str(exc), exc.request_id
|
|
279
|
+
except MailError as exc:
|
|
280
|
+
message.status, message.note = "failed", str(exc)
|
|
281
|
+
except Exception: # noqa: BLE001 - persist a safe, retryable failure without upstream secrets
|
|
282
|
+
message.status, message.note = "failed", "The service could not finish this job. Retry when the connection is available."
|
|
283
|
+
log.warning("Mail job %s failed", message.id) # no upstream secrets or email content
|
|
284
|
+
message.updated_at = now()
|
|
285
|
+
await db.commit()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def tick():
|
|
289
|
+
async with lock, session() as db:
|
|
290
|
+
cfg = await config(db)
|
|
291
|
+
if not cfg.get("enabled"):
|
|
292
|
+
return
|
|
293
|
+
try:
|
|
294
|
+
await sync(db, cfg)
|
|
295
|
+
except Exception as exc: # noqa: BLE001 - keep jobs moving when mailbox sync is unavailable
|
|
296
|
+
latest = await config(db)
|
|
297
|
+
await settings_store.put(db, "mail", {**latest, "sync_error": str(exc) if isinstance(exc, MailError)
|
|
298
|
+
else "Mailbox sync failed. Check the connection and access permissions."})
|
|
299
|
+
jobs = (await db.scalars(select(MailMessage).where(
|
|
300
|
+
MailMessage.source == source(cfg), MailMessage.status.in_(["queued", "running", "pending"])
|
|
301
|
+
).order_by(MailMessage.created_at).limit(50))).all()
|
|
302
|
+
for message in jobs:
|
|
303
|
+
await advance(db, cfg, message)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def worker():
|
|
307
|
+
while True:
|
|
308
|
+
try:
|
|
309
|
+
await tick()
|
|
310
|
+
except Exception: # noqa: BLE001 - one cycle must not stop the worker
|
|
311
|
+
log.warning("Mail worker could not complete a cycle")
|
|
312
|
+
await asyncio.sleep(60)
|