flowlit-integration 0.1.0__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.
@@ -0,0 +1,26 @@
1
+ """flowlit-integration: reusable flowlit executors for common external
2
+ systems (Harvest, Google Sheets, Gmail, Slack) and generic data output
3
+ (spreadsheets), plus the shared `CredentialsProvider` abstraction they
4
+ resolve their secrets through.
5
+
6
+ This package sits in the middle of a three-tier hierarchy:
7
+
8
+ flowlit -> flowlit-integration -> <client application>
9
+
10
+ `flowlit` is the execution engine (DAG plans, the `Executor` port, the
11
+ `EventBus`) and has no knowledge of any specific external system.
12
+ `flowlit-integration` knows how to talk to a handful of common ones, but
13
+ nothing about any particular business's use of them. A client
14
+ application (e.g. `ccq-automation`) imports the executors it needs from
15
+ here, registers them with `flowlit` itself
16
+ (`flowlit.register_executor(step_type, executor_instance)`), and wires
17
+ them into its own business-specific DAGs.
18
+
19
+ Registration is deliberately *not* automatic: importing this package, or
20
+ any module in it, performs no registration and no I/O. A client calls
21
+ `flowlit.register_executor(...)` explicitly for each executor it wants,
22
+ the same way it would for an executor it wrote itself -- see each
23
+ executor's own module docstring for its step-type name and spec shape.
24
+ """
25
+
26
+ from __future__ import annotations
@@ -0,0 +1,10 @@
1
+ """The `CredentialsProvider` abstraction shared by every executor in this
2
+ package: a small chain of sources (explicit value > environment variable
3
+ > credentials file > future secret-manager extension point) plus typed,
4
+ per-integration resolver functions.
5
+
6
+ Populated by the "Move Generic Executors & Credentials Provider into
7
+ flowlit-integration" task; currently empty scaffolding.
8
+ """
9
+
10
+ from __future__ import annotations
@@ -0,0 +1,419 @@
1
+ """Pluggable credential resolution, shared by every executor in this
2
+ package.
3
+
4
+ Why this exists
5
+ ----------------
6
+ Originally written for a Google Sheets proof-of-concept that established
7
+ a precedent worth keeping: try more than one source, in a defined order,
8
+ and fall through silently to the next one rather than hard-failing on
9
+ the first miss. Its precedence was, informally, *inline value > file
10
+ path > environment variable*. Rather than re-implement that same
11
+ fallback chain by hand in each executor (Harvest, Google Sheets, Gmail,
12
+ Slack) — the kind of copy-paste that quietly drifts out of sync the
13
+ moment one executor needs a fifth source — this module gives every
14
+ executor one small, testable abstraction: :class:`CredentialsProvider`.
15
+
16
+ Supported sources, and precedence
17
+ ----------------------------------
18
+ A caller-supplied :class:`CredentialsProvider` (typically the one
19
+ :func:`default_credentials_provider` builds) resolves a single named key
20
+ (e.g. ``"HARVEST_TOKEN"``) by trying, in order:
21
+
22
+ 1. **Explicit / inline value** — not part of the provider chain at all:
23
+ any resolver function below (e.g. :func:`resolve_harvest_credentials`)
24
+ accepts the fully-resolved value as a keyword argument, and uses it
25
+ as-is, before ever consulting a provider. This is what lets a step
26
+ ``spec`` (or a test) hand in a credential directly, same as the POC's
27
+ own ``credentials_json``/``credentials_path`` spec fields do today.
28
+ 2. **Environment variable** — :class:`EnvCredentialsProvider`, reading
29
+ ``os.environ``.
30
+ 3. **Credentials file** — :class:`FileCredentialsProvider`, a flat JSON
31
+ object on disk (``{"HARVEST_TOKEN": "...", ...}``) — the standard
32
+ local-dev / container-mounted-secret pattern (a Docker/Kubernetes
33
+ secrets file, an Azure App Service "Configuration" file mount, etc.).
34
+ 4. **(Documented, not yet implemented) a cloud secret manager** — e.g.
35
+ Azure Key Vault or AWS Secrets Manager, for a client that deploys
36
+ there (see ``docs/credentials.md``). Adding it later means writing
37
+ one more ``CredentialsProvider`` and adding it to the chain
38
+ :func:`default_credentials_provider` builds — no executor changes.
39
+
40
+ Sources 2–4 are what :class:`ChainedCredentialsProvider` implements:
41
+ try each configured provider in order, return the first non-``None``
42
+ hit. :func:`default_credentials_provider` is the single factory that
43
+ builds "the" chain callers actually use — keeping it the one place a
44
+ future source gets added.
45
+
46
+ Nothing in this module logs a secret's *value* — only the key being
47
+ looked up, and (for the file provider) the path being read. A missing
48
+ credentials file is not an error here: it just means that source
49
+ contributes nothing, so the chain falls through to the next one. A
50
+ malformed one (not valid JSON, or not a flat object) *is* treated as a
51
+ configuration bug and raises — silently ignoring a file that's actually
52
+ present but broken would hide a real mistake.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ import logging
59
+ import os
60
+ from abc import ABC, abstractmethod
61
+ from collections.abc import Sequence
62
+ from dataclasses import dataclass
63
+ from pathlib import Path
64
+ from typing import Any
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+
69
+ class MissingCredentialError(Exception):
70
+ """Raised by :meth:`CredentialsProvider.require` when `key` isn't
71
+ available from any configured source.
72
+
73
+ Carries `key` (never a resolved value) so callers/tests can assert
74
+ on *which* credential was missing without parsing the message.
75
+ """
76
+
77
+ def __init__(self, key: str) -> None:
78
+ self.key = key
79
+ super().__init__(
80
+ f"missing credential '{key}': not found in the environment, the "
81
+ "credentials file (if configured via FLOWLIT_INTEGRATION_CREDENTIALS_FILE), or any "
82
+ "other configured source. See docs/credentials.md."
83
+ )
84
+
85
+
86
+ class CredentialsProvider(ABC):
87
+ """A source of named credential values.
88
+
89
+ `get()` is the one method a concrete source implements. `require()`
90
+ is a free convenience built on top of it — resolvers below use it so
91
+ a missing credential fails with one clear, typed error instead of a
92
+ `KeyError`/`TypeError` surfacing wherever the `None` first gets used.
93
+ """
94
+
95
+ @abstractmethod
96
+ def get(self, key: str) -> str | None:
97
+ """Return the value for `key`, or `None` if this source has
98
+ nothing for it. Never raises for an ordinary "not found" — that
99
+ is exactly what `None` means. A source-level failure that isn't
100
+ an ordinary miss (e.g. a credentials file that exists but isn't
101
+ valid JSON) may still raise.
102
+ """
103
+
104
+ def require(self, key: str) -> str:
105
+ """Like `get()`, but raises `MissingCredentialError` instead of
106
+ returning `None`."""
107
+ value = self.get(key)
108
+ if value is None:
109
+ raise MissingCredentialError(key)
110
+ return value
111
+
112
+
113
+ class EnvCredentialsProvider(CredentialsProvider):
114
+ """Reads credentials from `os.environ`.
115
+
116
+ `prefix`, if given, is prepended to every key before the environment
117
+ lookup — e.g. `prefix="CCQ_"` makes `get("HARVEST_TOKEN")` read
118
+ `CCQ_HARVEST_TOKEN`. Defaults to no prefix, which is what every
119
+ resolver in this module actually uses (the documented env var names
120
+ in `docs/credentials.md` are read as-is).
121
+ """
122
+
123
+ def __init__(self, prefix: str = "") -> None:
124
+ self._prefix = prefix
125
+
126
+ def get(self, key: str) -> str | None:
127
+ return os.environ.get(f"{self._prefix}{key}")
128
+
129
+
130
+ class FileCredentialsProvider(CredentialsProvider):
131
+ """Reads credentials from a flat JSON file: `{"KEY": "value", ...}`.
132
+
133
+ The file is read lazily, on first `get()`/`require()` call, and
134
+ cached for the lifetime of this instance — not at construction time,
135
+ so building one costs nothing and doesn't require the file to exist
136
+ yet (it may be mounted into a container after this provider is
137
+ constructed but before the first credential lookup, for example).
138
+
139
+ A missing file is treated as "no credentials from this source" —
140
+ `get()` returns `None` for every key, exactly as if the file were
141
+ present but empty — so a :class:`ChainedCredentialsProvider` falls
142
+ through to its next source rather than hard-failing. A file that
143
+ *exists* but isn't valid JSON, or isn't a flat `{str: str}` object,
144
+ raises `ValueError` — that's a real misconfiguration, not an absent
145
+ optional source.
146
+ """
147
+
148
+ def __init__(self, path: str | Path) -> None:
149
+ self._path = Path(path)
150
+ self._values: dict[str, str] | None = None
151
+
152
+ def _load(self) -> dict[str, str]:
153
+ if self._values is not None:
154
+ return self._values
155
+ if not self._path.is_file():
156
+ logger.debug("credentials file %s not found; skipping", self._path)
157
+ self._values = {}
158
+ return self._values
159
+ try:
160
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
161
+ except (OSError, json.JSONDecodeError) as exc:
162
+ raise ValueError(f"credentials file {self._path} is not valid JSON: {exc}") from exc
163
+ if not isinstance(raw, dict) or not all(isinstance(v, str) for v in raw.values()):
164
+ raise ValueError(
165
+ f"credentials file {self._path} must be a flat JSON object of "
166
+ "string keys to string values"
167
+ )
168
+ logger.debug("loaded %d credential key(s) from %s", len(raw), self._path)
169
+ self._values = raw
170
+ return self._values
171
+
172
+ def get(self, key: str) -> str | None:
173
+ return self._load().get(key)
174
+
175
+
176
+ class ChainedCredentialsProvider(CredentialsProvider):
177
+ """Tries a sequence of providers in order, returning the first hit.
178
+
179
+ This is the "more than one supply method" contract itself: each
180
+ configured provider is asked in turn, and the first one to return a
181
+ non-`None` value for `key` wins. An empty/absent source (e.g.
182
+ `FileCredentialsProvider` over a file that doesn't exist) simply
183
+ contributes nothing and the chain moves on — see
184
+ `default_credentials_provider` for the concrete order this package
185
+ uses by default.
186
+ """
187
+
188
+ def __init__(self, providers: Sequence[CredentialsProvider]) -> None:
189
+ self._providers = list(providers)
190
+
191
+ def get(self, key: str) -> str | None:
192
+ for provider in self._providers:
193
+ value = provider.get(key)
194
+ if value is not None:
195
+ return value
196
+ return None
197
+
198
+
199
+ _CREDENTIALS_FILE_ENV_VAR = "FLOWLIT_INTEGRATION_CREDENTIALS_FILE"
200
+
201
+
202
+ def default_credentials_provider() -> CredentialsProvider:
203
+ """Build the composite provider every executor resolves credentials
204
+ through, by default.
205
+
206
+ Order: environment variables, then — only if `FLOWLIT_INTEGRATION_CREDENTIALS_FILE`
207
+ names an existing file — that file. This is the single place a
208
+ future source (e.g. a cloud secret manager, for a client that needs
209
+ one) gets added: one more provider appended to the list
210
+ here, with no executor-level changes needed.
211
+
212
+ Callers that want a different order, an extra source, or a fixed set
213
+ of values (tests, mainly) can construct their own
214
+ `CredentialsProvider` instead — every executor accepts one via its
215
+ constructor, defaulting to this factory.
216
+ """
217
+ providers: list[CredentialsProvider] = [EnvCredentialsProvider()]
218
+ file_path = os.environ.get(_CREDENTIALS_FILE_ENV_VAR)
219
+ if file_path and os.path.isfile(file_path):
220
+ providers.append(FileCredentialsProvider(file_path))
221
+ return ChainedCredentialsProvider(providers)
222
+
223
+
224
+ # --------------------------------------------------------------------------
225
+ # Per-integration typed credentials + resolvers.
226
+ #
227
+ # Each dataclass documents exactly which keys it reads. The same names
228
+ # are used both as environment variable names and as keys in the
229
+ # credentials-file JSON, so the two sources are drop-in interchangeable
230
+ # — moving from one to the other never means renaming anything.
231
+ # --------------------------------------------------------------------------
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class HarvestCredentials:
236
+ """Harvest API v2 auth: a personal access token plus the account it
237
+ scopes requests to.
238
+
239
+ Reads (env var / credentials-file key):
240
+ HARVEST_TOKEN: the personal access token, sent as
241
+ ``Authorization: Bearer <token>``.
242
+ HARVEST_ACCOUNT_ID: sent as the ``Harvest-Account-ID`` header.
243
+ """
244
+
245
+ token: str
246
+ account_id: str
247
+
248
+
249
+ def resolve_harvest_credentials(
250
+ provider: CredentialsProvider | None = None,
251
+ *,
252
+ token: str | None = None,
253
+ account_id: str | None = None,
254
+ ) -> HarvestCredentials:
255
+ """Resolve :class:`HarvestCredentials`.
256
+
257
+ `token`/`account_id`, if given, are used as-is (the "explicit /
258
+ inline" precedence tier — see the module docstring) and skip
259
+ `provider` entirely for that field. Otherwise falls back to
260
+ `provider` (defaulting to :func:`default_credentials_provider`),
261
+ which raises `MissingCredentialError` if the key isn't available
262
+ from any of its configured sources.
263
+ """
264
+ resolved_provider = provider if provider is not None else default_credentials_provider()
265
+ return HarvestCredentials(
266
+ token=token if token is not None else resolved_provider.require("HARVEST_TOKEN"),
267
+ account_id=(
268
+ account_id
269
+ if account_id is not None
270
+ else resolved_provider.require("HARVEST_ACCOUNT_ID")
271
+ ),
272
+ )
273
+
274
+
275
+ @dataclass(frozen=True)
276
+ class SlackCredentials:
277
+ """Slack Web API auth: a bot token.
278
+
279
+ Reads (env var / credentials-file key):
280
+ SLACK_BOT_TOKEN: sent as ``Authorization: Bearer <token>`` on
281
+ calls to Slack's Web API (e.g. ``chat.postMessage``).
282
+ """
283
+
284
+ bot_token: str
285
+
286
+
287
+ def resolve_slack_credentials(
288
+ provider: CredentialsProvider | None = None,
289
+ *,
290
+ bot_token: str | None = None,
291
+ ) -> SlackCredentials:
292
+ """Resolve :class:`SlackCredentials`. See
293
+ :func:`resolve_harvest_credentials` for the explicit-value /
294
+ provider precedence this follows."""
295
+ resolved_provider = provider if provider is not None else default_credentials_provider()
296
+ return SlackCredentials(
297
+ bot_token=(
298
+ bot_token if bot_token is not None else resolved_provider.require("SLACK_BOT_TOKEN")
299
+ )
300
+ )
301
+
302
+
303
+ # Deliberately not included here: a Slack *signing-secret* resolver (for
304
+ # verifying inbound webhook requests). None of this package's executors
305
+ # need it -- it's an inbound-transport concern, specific to whatever
306
+ # client exposes a Slack webhook endpoint. A client needing it builds a
307
+ # small resolver of its own on top of `CredentialsProvider`/
308
+ # `default_credentials_provider` above, the same way this module's own
309
+ # resolvers do -- see `docs/architecture.md`.
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class GmailCredentials:
314
+ """Gmail SMTP auth: a sending address plus an app password.
315
+
316
+ Reads (env var / credentials-file key):
317
+ GMAIL_SMTP_USERNAME: the full Gmail address to authenticate and
318
+ send as (e.g. ``reports@example.com``).
319
+ GMAIL_SMTP_APP_PASSWORD: a Google Account *app password* (not
320
+ the account's normal login password) — see
321
+ ``docs/credentials.md`` for how to generate one.
322
+ """
323
+
324
+ username: str
325
+ app_password: str
326
+
327
+
328
+ def resolve_gmail_credentials(
329
+ provider: CredentialsProvider | None = None,
330
+ *,
331
+ username: str | None = None,
332
+ app_password: str | None = None,
333
+ ) -> GmailCredentials:
334
+ """Resolve :class:`GmailCredentials`. See
335
+ :func:`resolve_harvest_credentials` for the explicit-value /
336
+ provider precedence this follows."""
337
+ resolved_provider = provider if provider is not None else default_credentials_provider()
338
+ return GmailCredentials(
339
+ username=(
340
+ username
341
+ if username is not None
342
+ else resolved_provider.require("GMAIL_SMTP_USERNAME")
343
+ ),
344
+ app_password=(
345
+ app_password
346
+ if app_password is not None
347
+ else resolved_provider.require("GMAIL_SMTP_APP_PASSWORD")
348
+ ),
349
+ )
350
+
351
+
352
+ @dataclass(frozen=True)
353
+ class GoogleSheetsCredentials:
354
+ """Google service-account credentials for `gspread`.
355
+
356
+ Structurally different from the other three: a service-account
357
+ credential is a multi-field JSON document, not a single flat secret
358
+ string, so this holds *either* the inline JSON (as a string or an
359
+ already-parsed dict) *or* a file path to it — exactly the two forms
360
+ `google.oauth2.service_account.Credentials` already knows how to
361
+ load from (`from_service_account_info` / `from_service_account_file`)
362
+ — never both. `google_sheets_executor.py` does the actual parsing;
363
+ this dataclass only carries *which* form is available and where.
364
+
365
+ At most one of `credentials_json` / `credentials_path` is set. Both
366
+ `None` means: no credential was found from any source, including the
367
+ `GOOGLE_APPLICATION_CREDENTIALS` convention — callers fall back to
368
+ `gspread`'s own default discovery (`gspread.service_account()`),
369
+ matching the POC's behavior.
370
+ """
371
+
372
+ credentials_json: str | dict[str, Any] | None
373
+ credentials_path: str | None
374
+
375
+
376
+ def resolve_google_sheets_credentials(
377
+ provider: CredentialsProvider | None = None,
378
+ *,
379
+ credentials_json: str | dict[str, Any] | None = None,
380
+ credentials_path: str | None = None,
381
+ ) -> GoogleSheetsCredentials:
382
+ """Resolve :class:`GoogleSheetsCredentials`, preserving the POC's
383
+ (`flowlite-client/google_sheet_executer.py`) precedence:
384
+
385
+ 1. `credentials_json` — explicit inline JSON (string or dict) passed
386
+ by the caller (e.g. a step `spec` field). Used as-is.
387
+ 2. `credentials_path` — explicit file path passed by the caller.
388
+ Used as-is (existence is checked when it's actually loaded).
389
+ 3. `GOOGLE_CREDENTIALS_JSON` — inline JSON, resolved via `provider`
390
+ (env var or credentials file — either works, since a
391
+ `CredentialsProvider` doesn't care which source produced it).
392
+ 4. `GOOGLE_APPLICATION_CREDENTIALS` — a file path, resolved via
393
+ `provider`. This is the same env var name Google's own client
394
+ libraries conventionally use (Application Default Credentials),
395
+ kept for drop-in compatibility with the POC and with any Google
396
+ tooling already configured in the environment.
397
+ 5. Neither found: both fields come back `None` — the executor falls
398
+ back to `gspread.service_account()` (its own default local
399
+ discovery), exactly like the POC.
400
+
401
+ Only the *env var lookup* leg (steps 3–4) actually goes through
402
+ `provider` — the POC's own file-path/inline-JSON precedence (steps
403
+ 1–2) is preserved as-is rather than being routed through the
404
+ provider chain, since those are per-call spec values, not named
405
+ secrets a `CredentialsProvider` source would hold.
406
+ """
407
+ if credentials_json is not None:
408
+ return GoogleSheetsCredentials(credentials_json=credentials_json, credentials_path=None)
409
+ if credentials_path is not None:
410
+ return GoogleSheetsCredentials(credentials_json=None, credentials_path=credentials_path)
411
+
412
+ resolved_provider = provider if provider is not None else default_credentials_provider()
413
+ env_json = resolved_provider.get("GOOGLE_CREDENTIALS_JSON")
414
+ if env_json is not None:
415
+ return GoogleSheetsCredentials(credentials_json=env_json, credentials_path=None)
416
+ env_path = resolved_provider.get("GOOGLE_APPLICATION_CREDENTIALS")
417
+ if env_path is not None:
418
+ return GoogleSheetsCredentials(credentials_json=None, credentials_path=env_path)
419
+ return GoogleSheetsCredentials(credentials_json=None, credentials_path=None)
@@ -0,0 +1,10 @@
1
+ """Reusable flowlit executors, one per file, one per external system (or
2
+ generic capability). Nothing in this package imports the others'
3
+ optional third-party SDKs at module-import time beyond what that one
4
+ executor needs -- see `pyproject.toml`'s per-integration extras.
5
+
6
+ Populated by the "Move Generic Executors & Credentials Provider into
7
+ flowlit-integration" task; currently empty scaffolding.
8
+ """
9
+
10
+ from __future__ import annotations
@@ -0,0 +1,220 @@
1
+ """Gmail step executor: outbound email dispatch, with attachment support.
2
+
3
+ Handles step type `"gmail"` once a client registers it (e.g.
4
+ `flowlit.register_executor("gmail", GmailExecutor())` — see this
5
+ package's own README/`docs/architecture.md`; registration is always the
6
+ client's own explicit job, never automatic).
7
+
8
+ Approach: SMTP via Gmail's SMTP endpoint (`smtp.gmail.com:587`,
9
+ STARTTLS), authenticated with a Google Account *app password* — not
10
+ `aiosmtplib` (a native-async SMTP library), and not full OAuth2. Chosen
11
+ for three reasons, in order of weight:
12
+
13
+ 1. **Standard, well-documented practice for Gmail automation without the
14
+ OAuth consent-screen overhead.** An app password is a Google Account
15
+ feature purpose-built for exactly this (send mail as yourself from a
16
+ script/service account) — generate one under the account's Security
17
+ settings, no Google Cloud project, OAuth client, or consent screen
18
+ required. OAuth2 is the *better* answer for a multi-tenant product
19
+ sending on behalf of many different users' inboxes; this executor
20
+ sends from one fixed automation mailbox, which is exactly the case
21
+ app passwords are meant for.
22
+ 2. **`smtplib` is stdlib.** No extra dependency for what is, at bottom,
23
+ "connect, STARTTLS, log in, send" — one well-worn code path with
24
+ decades of Python usage behind it, versus a smaller, newer async-native
25
+ library. `aiosmtplib` would be a reasonable alternative if this
26
+ executor needed to hold many concurrent SMTP connections open at once;
27
+ it doesn't — sending one report/alert email per step is not a
28
+ throughput-sensitive path.
29
+ 3. **The blocking call is isolated, not ignored.** `smtplib.SMTP` is
30
+ synchronous, so every actual network call here runs inside
31
+ `asyncio.to_thread()` — same reasoning as `GoogleSheetsExecutor`
32
+ wrapping `gspread`: this process is a single-threaded, cooperative
33
+ event loop, and a blocking network call would freeze every other
34
+ concurrently-running step for as long as it takes.
35
+
36
+ Credentials: `GMAIL_SMTP_USERNAME` (the sending address) and
37
+ `GMAIL_SMTP_APP_PASSWORD` (the app password, *not* the account's normal
38
+ login password), resolved via `flowlit_integration.credentials.provider.
39
+ resolve_gmail_credentials` — see `config/credentials.py` for supported
40
+ sources.
41
+
42
+ Attachments are given as `{"filename": ..., "content_base64": ...}` —
43
+ inline base64-encoded bytes, not a filesystem path. This keeps the
44
+ executor's `spec` fully self-contained and serializable (a plan step's
45
+ `spec` is plain JSON-compatible data flowing through flowlit's own
46
+ output-placeholder substitution — e.g. a prior step's generated-report
47
+ bytes, base64-encoded, feeding straight into this one) rather than
48
+ depending on a shared filesystem between whatever step produced the
49
+ attachment and this one, which would break the moment either runs on a
50
+ different machine/container.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import asyncio
56
+ import base64
57
+ import binascii
58
+ import smtplib
59
+ from collections.abc import Mapping
60
+ from email.mime.application import MIMEApplication
61
+ from email.mime.multipart import MIMEMultipart
62
+ from email.mime.text import MIMEText
63
+ from typing import Any
64
+
65
+ from flowlit.application.ports.executor import ExecutorResult
66
+ from flowlit.infrastructure.executors.base_executor import BaseExecutor
67
+ from pydantic import BaseModel, Field, field_validator
68
+
69
+ from flowlit_integration.credentials.provider import CredentialsProvider, resolve_gmail_credentials
70
+
71
+ _SMTP_HOST = "smtp.gmail.com"
72
+ _SMTP_PORT = 587
73
+
74
+
75
+ class GmailAttachment(BaseModel):
76
+ """One email attachment.
77
+
78
+ filename (string, required): attachment file name, as the recipient
79
+ will see it (e.g. `"timesheet_report.csv"`).
80
+ content_base64 (string, required): the attachment's bytes,
81
+ base64-encoded.
82
+ """
83
+
84
+ filename: str
85
+ content_base64: str
86
+
87
+ @field_validator("filename")
88
+ @classmethod
89
+ def _filename_not_blank(cls, value: str) -> str:
90
+ if not value.strip():
91
+ raise ValueError("must be a non-empty string")
92
+ return value
93
+
94
+
95
+ class GmailSpec(BaseModel):
96
+ """Spec fields for a `"gmail"` step.
97
+
98
+ to (list of string, required): recipient email addresses.
99
+ subject (string, required): email subject line.
100
+ body (string, required): message body.
101
+ is_html (bool, optional): whether `body` is HTML rather than plain
102
+ text. Defaults to `False`.
103
+ attachments (list of `GmailAttachment`, optional): defaults to none.
104
+ cc (list of string, optional): CC recipients. Defaults to none.
105
+ """
106
+
107
+ to: list[str] = Field(min_length=1)
108
+ subject: str
109
+ body: str
110
+ is_html: bool = False
111
+ attachments: list[GmailAttachment] = Field(default_factory=list)
112
+ cc: list[str] = Field(default_factory=list)
113
+
114
+ @field_validator("to")
115
+ @classmethod
116
+ def _to_non_blank(cls, value: list[str]) -> list[str]:
117
+ if not all(addr.strip() for addr in value):
118
+ raise ValueError("must not contain blank addresses")
119
+ return value
120
+
121
+ @field_validator("subject")
122
+ @classmethod
123
+ def _subject_not_blank(cls, value: str) -> str:
124
+ if not value.strip():
125
+ raise ValueError("must be a non-empty string")
126
+ return value
127
+
128
+
129
+ class GmailExecutor(BaseExecutor):
130
+ """Implements `flowlit.application.ports.executor.Executor` (via
131
+ `BaseExecutor`) for outbound Gmail dispatch. See module docstring
132
+ for the SMTP-via-app-password approach and why it was chosen.
133
+
134
+ Validated against `GmailSpec` (see `BaseExecutor.spec_model`).
135
+ """
136
+
137
+ spec_model = GmailSpec
138
+
139
+ def __init__(self, credentials_provider: CredentialsProvider | None = None) -> None:
140
+ super().__init__()
141
+ self._credentials_provider = credentials_provider
142
+
143
+ def _sync_send(self, spec: Mapping[str, Any], username: str, app_password: str) -> None:
144
+ """Build and send the MIME message. Runs inside `asyncio.to_thread`."""
145
+ message = MIMEMultipart()
146
+ message["From"] = username
147
+ message["To"] = ", ".join(spec["to"])
148
+ message["Subject"] = spec["subject"]
149
+ if spec.get("cc"):
150
+ message["Cc"] = ", ".join(spec["cc"])
151
+
152
+ body_subtype = "html" if spec.get("is_html") else "plain"
153
+ message.attach(MIMEText(spec["body"], body_subtype))
154
+
155
+ for attachment in spec.get("attachments", []):
156
+ part = MIMEApplication(base64.b64decode(attachment["content_base64"]))
157
+ part.add_header(
158
+ "Content-Disposition", "attachment", filename=attachment["filename"]
159
+ )
160
+ message.attach(part)
161
+
162
+ all_recipients = [*spec["to"], *spec.get("cc", [])]
163
+
164
+ with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=30) as smtp:
165
+ smtp.starttls()
166
+ smtp.login(username, app_password)
167
+ smtp.sendmail(username, all_recipients, message.as_string())
168
+
169
+ async def _run(self, step_id: str, spec: Mapping[str, Any]) -> ExecutorResult:
170
+ try:
171
+ credentials = resolve_gmail_credentials(self._credentials_provider)
172
+ except Exception as exc: # MissingCredentialError, or a bad credentials file
173
+ self._logger.warning("step %s (gmail): credential resolution failed: %s", step_id, exc)
174
+ return ExecutorResult(success=False, error=f"Gmail credentials unavailable: {exc}")
175
+
176
+ for attachment in spec.get("attachments", []):
177
+ try:
178
+ base64.b64decode(attachment["content_base64"], validate=True)
179
+ except (binascii.Error, ValueError) as exc:
180
+ return ExecutorResult(
181
+ success=False,
182
+ error=(
183
+ f"attachment '{attachment['filename']}' has invalid "
184
+ f"base64 content: {exc}"
185
+ ),
186
+ )
187
+
188
+ self._logger.info(
189
+ "step %s (gmail): sending '%s' to %d recipient(s)",
190
+ step_id,
191
+ spec["subject"],
192
+ len(spec["to"]),
193
+ )
194
+
195
+ try:
196
+ await asyncio.to_thread(
197
+ self._sync_send, spec, credentials.username, credentials.app_password
198
+ )
199
+ except smtplib.SMTPAuthenticationError as exc:
200
+ err_msg = f"Gmail SMTP authentication failed: {exc}"
201
+ self._logger.warning("step %s (gmail): %s", step_id, err_msg)
202
+ return ExecutorResult(success=False, error=err_msg)
203
+ except smtplib.SMTPException as exc:
204
+ err_msg = f"Gmail SMTP error: {type(exc).__name__}: {exc}"
205
+ self._logger.warning("step %s (gmail): %s", step_id, err_msg)
206
+ return ExecutorResult(success=False, error=err_msg)
207
+ except OSError as exc:
208
+ err_msg = f"Could not connect to {_SMTP_HOST}:{_SMTP_PORT}: {exc}"
209
+ self._logger.warning("step %s (gmail): %s", step_id, err_msg)
210
+ return ExecutorResult(success=False, error=err_msg)
211
+
212
+ self._logger.info("step %s (gmail): sent '%s'", step_id, spec["subject"])
213
+ return ExecutorResult(
214
+ success=True,
215
+ output={
216
+ "to": spec["to"],
217
+ "subject": spec["subject"],
218
+ "attachment_count": len(spec.get("attachments", [])),
219
+ },
220
+ )