@m13v/s4l 1.7.1-rc.2 → 1.7.1-rc.21

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.
Files changed (45) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +332 -24
  3. package/mcp/dist/onboarding.js +3 -10
  4. package/mcp/dist/panel.html +42 -31
  5. package/mcp/dist/product-link.html +1 -1
  6. package/mcp/dist/runtime.js +101 -1
  7. package/mcp/dist/telemetry.js +80 -3
  8. package/mcp/dist/version.json +2 -2
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +472 -96
  12. package/mcp/menubar/s4l_menubar.py +269 -26
  13. package/mcp/menubar/s4l_state.py +169 -23
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/mcp/shared/onboarding-ledger.cjs +1 -0
  17. package/package.json +3 -1
  18. package/scripts/active_users.py +3 -6
  19. package/scripts/autopilot_stall_watch.py +200 -16
  20. package/scripts/claude_job.py +6 -0
  21. package/scripts/feedback_digest.py +34 -6
  22. package/scripts/get-latest-staging-mcpb.sh +50 -0
  23. package/scripts/harness_overlay.py +45 -0
  24. package/scripts/identity.py +26 -0
  25. package/scripts/learned_preferences.py +241 -32
  26. package/scripts/link_tail.py +167 -91
  27. package/scripts/mark_event.py +128 -0
  28. package/scripts/memory_snapshot.py +62 -0
  29. package/scripts/pick_twitter_thread_target.py +1 -1
  30. package/scripts/release-mcpb.sh +48 -0
  31. package/scripts/reset-test-machine.sh +85 -3
  32. package/scripts/s4l_mode.py +7 -5
  33. package/scripts/scheduled_tasks_snapshot.py +107 -0
  34. package/scripts/send_gmail_report.py +76 -0
  35. package/scripts/sentry_digest.py +81 -189
  36. package/scripts/sentry_init.py +43 -15
  37. package/scripts/setup_twitter_auth.py +76 -38
  38. package/scripts/snapshot.py +20 -0
  39. package/scripts/twitter_post_plan.py +39 -13
  40. package/skill/dm-outreach-twitter.sh +19 -0
  41. package/skill/engage-twitter.sh +27 -0
  42. package/skill/run-draft-and-publish.sh +13 -12
  43. package/skill/run-linkedin.sh +9 -1
  44. package/skill/run-twitter-cycle.sh +199 -27
  45. package/skill/sentry-digest.sh +125 -12
@@ -45,6 +45,16 @@
45
45
  # the EXACT tested artifact ships to everyone. Nothing can drift between test and
46
46
  # ship because there is no repack.
47
47
  #
48
+ # FIXED STAGING DOWNLOAD LINK (2026-07-07, step 7b): stable has a permanent
49
+ # "latest" URL (releases/latest/download/social-autoposter.mcpb); staging did
50
+ # not, because GitHub's releases/latest hard-excludes prereleases with no
51
+ # override. Every --staging run now also mirrors the .mcpb onto a separate,
52
+ # non-version-tagged release ("staging-latest") so this URL never changes:
53
+ # https://github.com/m13v/s4l/releases/download/staging-latest/social-autoposter.mcpb
54
+ # The tag deliberately isn't semver-shaped, so version.ts/snapshot.py's real
55
+ # "newest rc" resolution (which requires a ^\d+\.\d+\.\d+ tag) ignores it —
56
+ # it is a convenience download link only, never a candidate update source.
57
+ #
48
58
  # Usage:
49
59
  # bash scripts/release-mcpb.sh # patch bump, npm + .mcpb + GitHub (STABLE)
50
60
  # bash scripts/release-mcpb.sh --bump minor
@@ -68,6 +78,12 @@ MCP_DIR="$REPO_ROOT/mcp"
68
78
  BUNDLE="$MCP_DIR/social-autoposter.mcpb"
69
79
  GH_REPO="m13v/s4l"
70
80
  SIZE_CAP_MB=180
81
+ # Floating alias release for a fixed "always the newest staging build" download
82
+ # link, mirroring what releases/latest/download/<asset> already gives stable.
83
+ # GitHub's own releases/latest excludes prereleases with no override, so there is
84
+ # no built-in equivalent for the rc channel — see step 7b below for why a
85
+ # non-semver tag here is safe (never confuses the real update-detection code).
86
+ STAGING_ALIAS_TAG="staging-latest"
71
87
 
72
88
  TAG_OVERRIDE=""
73
89
  VERSION_OVERRIDE=""
@@ -496,6 +512,38 @@ say "Released $TAG"
496
512
  echo " asset: social-autoposter.mcpb (${MB}MB)"
497
513
  [[ -n "$URL" ]] && echo " $URL"
498
514
 
515
+ # ---- 7b. Fixed "always latest staging" download link ------------------------
516
+ # Mirrors the asset just uploaded above onto a SEPARATE release tagged
517
+ # "$STAGING_ALIAS_TAG" (not a version), so the download URL never changes:
518
+ # https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb
519
+ # Safe by construction: latestFromGithubListStaging() (mcp/src/version.ts) and
520
+ # _latest_from_github_list_staging() (scripts/snapshot.py) both require
521
+ # tag_name to match ^\d+\.\d+\.\d+ before treating a release as a candidate
522
+ # "newest" build; a tag literally named "staging-latest" fails that check and
523
+ # is silently skipped by both, so this alias can never get mistaken for a real
524
+ # rc and can never confuse which build a box installs. It exists ONLY to give
525
+ # humans (and scripts) one fixed bookmark — actual boxes still resolve "newest"
526
+ # from the real per-release rc tags via the releases LIST endpoint, unchanged.
527
+ if [[ "$DO_STAGING" == "1" ]]; then
528
+ say "Mirroring asset onto the fixed staging-latest alias"
529
+ ALIAS_NOTES="Always mirrors the newest staging pre-release's .mcpb asset (currently $TAG).
530
+
531
+ Fixed download link (never changes): https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb
532
+
533
+ Not a real release — do not install from the tag page; use the asset link above. Actual per-build releases are tagged by version (e.g. $TAG)."
534
+ if gh release view "$STAGING_ALIAS_TAG" -R "$GH_REPO" >/dev/null 2>&1; then
535
+ gh release upload "$STAGING_ALIAS_TAG" "$BUNDLE" -R "$GH_REPO" --clobber
536
+ gh release edit "$STAGING_ALIAS_TAG" -R "$GH_REPO" --notes "$ALIAS_NOTES" >/dev/null
537
+ else
538
+ gh release create "$STAGING_ALIAS_TAG" "$BUNDLE" \
539
+ -R "$GH_REPO" \
540
+ --title "social-autoposter (staging — always latest)" \
541
+ --notes "$ALIAS_NOTES" \
542
+ --prerelease
543
+ fi
544
+ echo " https://github.com/$GH_REPO/releases/download/$STAGING_ALIAS_TAG/social-autoposter.mcpb"
545
+ fi
546
+
499
547
  # ---- 8. Verify the update banner will fire ---------------------------------
500
548
  # The menu-bar "⬆ Update available" banner (mcp/src/version.ts::versionStatus)
501
549
  # resolves "latest" from GitHub releases/latest, which is what .mcpb boxes (no
@@ -17,9 +17,12 @@
17
17
  # disturbing the rest of the environment.
18
18
  # --deep FULL NUKE — the plugin reset above PLUS the shared layer it touches:
19
19
  # per-agent Chrome profiles + cookies (reddit/linkedin/twitter), the
20
- # browser-harness backend, and the packaged toolchain (uv binary, uv
21
- # cache, Chromium ~1.7G). Other tools that rely on uv/Chromium
22
- # re-provision afterward. Use to reproduce a true bare-metal box.
20
+ # browser-harness backend, the packaged toolchain (uv binary + uv
21
+ # data/cache, Chromium ~1.7G), the uv PATH shim in your shell rc, the
22
+ # real Google Chrome.app the provisioner installs, and Google's
23
+ # Keystone updater. Reproduces a genuinely bare-metal box (as if S4L
24
+ # had never run). Other tools that rely on uv/Chrome/Chromium
25
+ # re-provision afterward — intended for DEDICATED test machines.
23
26
  #
24
27
  # Usage:
25
28
  # scripts/reset-test-machine.sh # dry run: print what the plugin reset WOULD remove
@@ -205,6 +208,27 @@ echo "[1] MCP state dir (uv-owned python, venv, materialized repo, runtime.json,
205
208
  rm_path "$HOME_DIR/.social-autoposter-mcp" "state"
206
209
  echo
207
210
 
211
+ # ---- 1a. compat symlink ~/social-autoposter -> materialized repo -----------
212
+ # The .mcpb provisioner plants ~/social-autoposter as a symlink INTO the state
213
+ # dir (so the pipeline's hardcoded $HOME/social-autoposter paths resolve). The
214
+ # state-dir wipe in [1] leaves it DANGLING. Remove it — but ONLY when it is a
215
+ # symlink resolving into .social-autoposter-mcp, so a developer's real clone (or
216
+ # their own ~/social-autoposter symlink to a working tree) is never touched.
217
+ echo "[1a] compat symlink ~/social-autoposter (plugin-planted)"
218
+ SA_LINK="$HOME_DIR/social-autoposter"
219
+ if [ -L "$SA_LINK" ]; then
220
+ SA_TARGET="$(readlink "$SA_LINK" 2>/dev/null || true)"
221
+ case "$SA_TARGET" in
222
+ *.social-autoposter-mcp/*|*.social-autoposter-mcp) rm_path "$SA_LINK" "compat-symlink" ;;
223
+ *) echo " (leaving $SA_LINK -> $SA_TARGET — not a plugin-planted symlink)" ;;
224
+ esac
225
+ elif [ -e "$SA_LINK" ]; then
226
+ echo " (leaving $SA_LINK — a real directory/clone, not a plugin-planted symlink)"
227
+ else
228
+ echo " (no ~/social-autoposter symlink)"
229
+ fi
230
+ echo
231
+
208
232
  # ---- 1b. Claude Desktop .mcpb extension -----------------------------------
209
233
  # A .mcpb (Desktop) install scatters THREE artifacts under ~/Library/Application
210
234
  # Support/Claude/, NONE of which live in the state dir removed in [1]: the
@@ -532,6 +556,23 @@ if [ "$DEEP" -eq 1 ]; then
532
556
  rm_path "$HOME_DIR/.local/bin/uv" "uv-bin"
533
557
  rm_path "$HOME_DIR/.local/bin/uvx" "uvx-bin"
534
558
  rm_path "$HOME_DIR/.cache/uv" "uv-cache"
559
+ # uv's DATA dir (installed standalone Pythons + tool venvs) and its PATH shim
560
+ # survive the bin removal — a bare box has neither. The uv installer also
561
+ # appends `. "$HOME/.local/bin/env"` to the shell rc files; strip that exact
562
+ # line so no uv PATH shim lingers.
563
+ rm_path "$HOME_DIR/.local/share/uv" "uv-data"
564
+ rm_path "$HOME_DIR/.local/bin/env" "uv-env-script"
565
+ for rc in "$HOME_DIR/.zshrc" "$HOME_DIR/.zprofile" "$HOME_DIR/.profile" \
566
+ "$HOME_DIR/.bash_profile" "$HOME_DIR/.bashrc"; do
567
+ [ -f "$rc" ] || continue
568
+ if grep -q '\.local/bin/env' "$rc" 2>/dev/null; then
569
+ echo " edit strip uv env-shim line from $rc"
570
+ if [ "$DRY" -eq 0 ]; then
571
+ cp "$rc" "$rc.bak" 2>/dev/null || true
572
+ grep -v '\.local/bin/env' "$rc" > "$rc.tmp" 2>/dev/null && mv "$rc.tmp" "$rc"
573
+ fi
574
+ fi
575
+ done
535
576
  echo " WARN --deep removed uv + ms-playwright; other tools on this machine that rely on them will re-provision."
536
577
  else
537
578
  echo " (left uv + ~/Library/Caches/ms-playwright + ~/.cache/uv in place)"
@@ -539,6 +580,47 @@ fi
539
580
  fi
540
581
  echo
541
582
 
583
+ # ---- 6b. DEEP: packaged Google Chrome + its Keystone updater ----------------
584
+ # The provisioner installs a REAL Google Chrome.app (runtime.ts Step 7) — the
585
+ # browser the scanner drives over CDP — that NO other reset step removes, plus
586
+ # installing it drops Google's Keystone auto-updater (LaunchAgents + support
587
+ # dirs). A bare box had none of this. --deep removes all of it so the Chrome
588
+ # install step is exercised fresh. NOTE: this touches the machine's Google Chrome
589
+ # globally, not just S4L — hence --deep / test-box only.
590
+ if [ "$PLUGIN_ONLY" -eq 1 ]; then
591
+ echo "[6b] packaged Google Chrome + Keystone updater — skipped (plugin reset; pass --deep to remove)"
592
+ else
593
+ echo "[6b] packaged Google Chrome + Keystone updater — $([ "$DEEP" -eq 1 ] && echo ENABLED || echo 'skipped (pass --deep)')"
594
+ if [ "$DEEP" -eq 1 ]; then
595
+ # Stop Chrome + unload the Keystone/updater agents before deleting their plists.
596
+ if [ "$DRY" -eq 0 ]; then
597
+ pkill -f 'Google Chrome' 2>/dev/null || true
598
+ for lbl in com.google.keystone.agent com.google.keystone.xpcservice com.google.GoogleUpdater.wake; do
599
+ launchctl bootout "gui/$(id -u)/$lbl" 2>/dev/null || true
600
+ done
601
+ fi
602
+ rm_path "/Applications/Google Chrome.app" "google-chrome"
603
+ rm_path "$HOME_DIR/Applications/Google Chrome.app" "google-chrome-user"
604
+ for p in \
605
+ "$HOME_DIR/Library/Application Support/Google" \
606
+ "$HOME_DIR/Library/Google" \
607
+ "$HOME_DIR/Library/Caches/Google" \
608
+ "$HOME_DIR/Library/Caches/com.google.Chrome" \
609
+ "$HOME_DIR/Library/Caches/com.google.Keystone" \
610
+ "$HOME_DIR/Library/LaunchAgents/com.google.keystone.agent.plist" \
611
+ "$HOME_DIR/Library/LaunchAgents/com.google.keystone.xpcservice.plist" \
612
+ "$HOME_DIR/Library/LaunchAgents/com.google.GoogleUpdater.wake.plist" \
613
+ "$HOME_DIR/Library/LaunchAgents/com.google.GoogleUpdater.plist"; do
614
+ rm_path "$p" "google"
615
+ done
616
+ for f in "$HOME_DIR"/Library/Preferences/com.google.Chrome*.plist; do
617
+ [ -e "$f" ] && rm_path "$f" "google-pref"
618
+ done
619
+ echo " WARN --deep removed Google Chrome + Keystone updater machine-wide."
620
+ fi
621
+ fi
622
+ echo
623
+
542
624
  # ---- 7. settle & verify (defeat Claude auto-relaunch resurrection) ---------
543
625
  # Claude Desktop can RELAUNCH after step [0] — on auto-update (ShipIt), a macOS
544
626
  # re-open, or a login item — and a relaunched host re-materializes a skeleton
@@ -249,11 +249,13 @@ def set_split(value) -> float:
249
249
  def get_draft_only() -> bool:
250
250
  """Whether every cycle stops at review cards (DEFAULT: True).
251
251
 
252
- False (operator opt-out via `draft-only off`) means promotion-lane cycles
253
- POST autonomously behind the rolling virality bar. Persona-lane cycles are
254
- unaffected either way: they always stay draft-only so the user's own voice
255
- keeps human review. Reads the retired `autopilot` key (inverted) from
256
- mode.json files written before the 2026-07-06 rename.
252
+ False (operator opt-out via `draft-only off`) means EVERY lane (promotion
253
+ AND personal_brand) POSTs autonomously; promotion additionally runs behind
254
+ the rolling virality bar. As of 2026-07-08 this is a single global switch,
255
+ not promotion-only: see skill/run-draft-and-publish.sh's DRAFT_ONLY_FLAG
256
+ gate, which applies this value uniformly across lanes. Reads the retired
257
+ `autopilot` key (inverted) from mode.json files written before the
258
+ 2026-07-06 rename.
257
259
  """
258
260
  try:
259
261
  data = json.loads(mode_file().read_text())
@@ -159,6 +159,108 @@ def build_summary() -> dict:
159
159
  }
160
160
 
161
161
 
162
+ STATE_CACHE_PATH = os.path.join(
163
+ os.path.expanduser("~"), ".social-autoposter-mcp", "scheduled-tasks-last-state.json"
164
+ )
165
+
166
+
167
+ def _load_last_state() -> dict:
168
+ try:
169
+ with open(STATE_CACHE_PATH) as f:
170
+ return json.load(f)
171
+ except Exception:
172
+ return {}
173
+
174
+
175
+ def _save_last_state(state: dict) -> None:
176
+ try:
177
+ os.makedirs(os.path.dirname(STATE_CACHE_PATH), exist_ok=True)
178
+ tmp = f"{STATE_CACHE_PATH}.tmp.{os.getpid()}"
179
+ with open(tmp, "w") as f:
180
+ json.dump(state, f)
181
+ os.replace(tmp, STATE_CACHE_PATH)
182
+ except Exception:
183
+ pass
184
+
185
+
186
+ def _registry_key(reg: dict) -> str:
187
+ return f"{reg.get('data_dir')}/{reg.get('account')}/{reg.get('session')}"
188
+
189
+
190
+ def _report_registry_change(message: str, changes: list) -> None:
191
+ """Best-effort Sentry breadcrumb for a registry transition. Silent no-op if
192
+ sentry-sdk/S4L_REPO_DIR are unavailable, so this stays additive and never
193
+ threatens the script's primary --summary (heartbeat) path."""
194
+ try:
195
+ repo = os.environ.get("S4L_REPO_DIR")
196
+ if repo:
197
+ scripts_dir = os.path.join(repo, "scripts")
198
+ if scripts_dir not in sys.path:
199
+ sys.path.insert(0, scripts_dir)
200
+ import sentry_init
201
+
202
+ sentry_init.init()
203
+ sentry_init.capture_message(
204
+ message,
205
+ level="info",
206
+ tags={"component": "scheduled-tasks-snapshot", "issue": "registry_change"},
207
+ extra={"changes": changes},
208
+ )
209
+ sentry_init.flush()
210
+ except Exception:
211
+ pass
212
+
213
+
214
+ def diff_and_emit(per_registry: list) -> list:
215
+ """Compare the current per-registry worker_ids/enabled state against the
216
+ last observed state (cached locally) and emit ONE distinct event per real
217
+ transition: a registry gaining/losing the worker task, `enabled` flipping,
218
+ or a registry appearing/disappearing entirely. Silent when nothing changed.
219
+
220
+ This exists because reconstructing "when did the registry actually change"
221
+ previously required diffing dozens of raw --summary snapshots by hand (see
222
+ the Karol 2026-07-07 update-orphan investigation) — every poll logged full
223
+ state unconditionally, with no distinct signal for an actual transition.
224
+ Returns the list of change strings (empty if nothing changed). Best-effort:
225
+ never raises, and never touches the registry files themselves."""
226
+ changes: list = []
227
+ try:
228
+ last = _load_last_state()
229
+ current = {}
230
+ for reg in per_registry:
231
+ key = _registry_key(reg)
232
+ snap = {
233
+ "worker_ids": sorted(reg.get("worker_ids") or []),
234
+ "enabled": bool(reg.get("enabled")),
235
+ }
236
+ current[key] = snap
237
+ prev = last.get(key)
238
+ if prev is None:
239
+ if snap["worker_ids"]:
240
+ changes.append(
241
+ f"{key}: registry appeared with worker_ids={snap['worker_ids']} "
242
+ f"enabled={snap['enabled']}"
243
+ )
244
+ continue
245
+ if prev.get("worker_ids") != snap["worker_ids"] or prev.get("enabled") != snap["enabled"]:
246
+ changes.append(
247
+ f"{key}: worker_ids {prev.get('worker_ids')}->{snap['worker_ids']} "
248
+ f"enabled {prev.get('enabled')}->{snap['enabled']}"
249
+ )
250
+ for key, prev in last.items():
251
+ if key not in current and (prev or {}).get("worker_ids"):
252
+ changes.append(f"{key}: registry disappeared (had worker_ids={prev.get('worker_ids')})")
253
+
254
+ if changes:
255
+ message = "scheduled-tasks registry changed: " + "; ".join(changes)
256
+ sys.stderr.write(f"[scheduled-tasks-snapshot] {message}\n")
257
+ _report_registry_change(message, changes)
258
+ _save_last_state(current)
259
+ except Exception:
260
+ pass
261
+ return changes
262
+
263
+
162
264
  def main() -> int:
163
265
  parser = argparse.ArgumentParser(description=__doc__)
164
266
  parser.add_argument(
@@ -169,6 +271,11 @@ def main() -> int:
169
271
  args = parser.parse_args()
170
272
 
171
273
  summary = build_summary()
274
+ # Side effect deliberately confined to the standalone-script path (not
275
+ # build_summary(), which s4l_menubar.py also imports in-process on every
276
+ # warning capture — doing the diff there would cache-thrash and could
277
+ # double-report on every menu-bar poll instead of once per real change).
278
+ diff_and_emit(summary.get("per_registry") or [])
172
279
  if args.summary:
173
280
  sys.stdout.write(json.dumps(summary, separators=(",", ":")))
174
281
  else:
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ """Generic Gmail-API send helper for Claude-authored reports.
3
+
4
+ Extracted out of sentry_digest.py so any skill doc (Claude spawned in a
5
+ --dangerously-skip-permissions session) can send a report email without
6
+ duplicating the Gmail plumbing. Body is read from a file, not a CLI arg, so
7
+ large HTML/markdown reports don't hit shell quoting/length limits.
8
+
9
+ Same Gmail token as strike_alert.py / daily_stats_email.py: i@m13v.com via
10
+ ~/gmail-api/token_i_at_m13v.com.json.
11
+
12
+ Usage:
13
+ python3 scripts/send_gmail_report.py \\
14
+ --to i@m13v.com \\
15
+ --subject "[Sentry] s4l: 1 new critical issue" \\
16
+ --body-file /tmp/report.html \\
17
+ [--html] # body-file content is HTML (default: plain text)
18
+ """
19
+
20
+ import argparse
21
+ import base64
22
+ import os
23
+ import sys
24
+ from email.mime.text import MIMEText
25
+
26
+ GMAIL_TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
27
+ GMAIL_SCOPES = ["https://mail.google.com/"]
28
+
29
+
30
+ def _scrub_dashes(s):
31
+ if not s:
32
+ return s
33
+ return s.replace("—", ",").replace("–", ",")
34
+
35
+
36
+ def gmail_service():
37
+ from google.auth.transport.requests import Request
38
+ from google.oauth2.credentials import Credentials
39
+ from googleapiclient.discovery import build
40
+
41
+ creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
42
+ if creds.expired and creds.refresh_token:
43
+ creds.refresh(Request())
44
+ with open(GMAIL_TOKEN_PATH, "w") as f:
45
+ f.write(creds.to_json())
46
+ return build("gmail", "v1", credentials=creds)
47
+
48
+
49
+ def main():
50
+ parser = argparse.ArgumentParser()
51
+ parser.add_argument("--to", required=True)
52
+ parser.add_argument("--subject", required=True)
53
+ parser.add_argument("--body-file", required=True, help="Path to a file containing the email body.")
54
+ parser.add_argument("--html", action="store_true", help="Body file content is HTML (default: plain text).")
55
+ args = parser.parse_args()
56
+
57
+ with open(args.body_file) as f:
58
+ body = f.read()
59
+
60
+ subtype = "html" if args.html else "plain"
61
+ msg = MIMEText(body, subtype)
62
+ msg["to"] = args.to
63
+ msg["subject"] = _scrub_dashes(args.subject)
64
+
65
+ service = gmail_service()
66
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
67
+ result = service.users().messages().send(userId="me", body={"raw": raw}).execute()
68
+ print(f"Email sent: {result['id']}")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ try:
73
+ main()
74
+ except Exception as e:
75
+ print(f"ERROR sending email: {e}", file=sys.stderr)
76
+ sys.exit(1)