Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

slab: mail (mbsync + mu) integration in menubar

Adds a "Mail" menu item showing unread counts across ac-mail and jas-mail
maildirs. Includes inline actions to sync either/both channels in the
background and open the sync log. Polls mu sparingly (every ~30s) unless a
mbsync is in flight, in which case the status string shows a "syncing…"
suffix.

Also gitignores .claude/scheduled_tasks.lock so the runtime lock file
doesn't show up as untracked noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+72
+1
.gitignore
··· 362 362 silo/use-staging-wallet.mjs 363 363 .claude/settings.local.json 364 364 .claude/worktrees/ 365 + .claude/scheduled_tasks.lock 365 366 366 367 # Fedac build output (disk images, local bundles) 367 368 fedac/out/
+71
slab/bin/slab-menubar.py
··· 18 18 from AppKit import NSApplication, NSApplicationActivationPolicyAccessory 19 19 20 20 TAILSCALE_BIN = shutil.which("tailscale") or "/opt/homebrew/bin/tailscale" 21 + MBSYNC_BIN = shutil.which("mbsync") or "/opt/homebrew/bin/mbsync" 22 + MU_BIN = shutil.which("mu") or "/opt/homebrew/bin/mu" 21 23 22 24 SLAB_HOME = Path(os.environ.get("SLAB_HOME", os.path.expanduser("~/.local/share/slab"))) 23 25 SLAB_BIN = Path(os.environ.get("SLAB_BIN", os.path.expanduser("~/.local/bin"))) ··· 28 30 LID_LOG = SLAB_HOME / "logs" / "lidalive.log" 29 31 DAEMON_PLIST = Path.home() / "Library/LaunchAgents/computer.slab.daemon.plist" 30 32 MENUBAR_PLIST = Path.home() / "Library/LaunchAgents/computer.slab.menubar.plist" 33 + 34 + MAILDIR = Path(os.environ.get("AC_MAIL_MAILDIR", os.path.expanduser("~/.mail-all"))) 35 + MAIL_SYNC_LOG = MAILDIR / "sync.log" 36 + MAIL_UNREAD_QUERY = "flag:unread AND (maildir:/ac-mail/INBOX OR maildir:/jas-mail/INBOX)" 31 37 32 38 33 39 def count_files(path: Path) -> int: ··· 94 100 return peers 95 101 96 102 103 + def mail_unread_count() -> int | None: 104 + if not MAILDIR.exists(): 105 + return None 106 + try: 107 + out = subprocess.check_output( 108 + [MU_BIN, "find", "--format=plain", "--fields=l", MAIL_UNREAD_QUERY], 109 + text=True, stderr=subprocess.DEVNULL, timeout=5, 110 + ) 111 + except subprocess.CalledProcessError as e: 112 + if e.returncode == 4: 113 + return 0 114 + return None 115 + except (FileNotFoundError, subprocess.TimeoutExpired): 116 + return None 117 + return sum(1 for line in out.splitlines() if line.strip()) 118 + 119 + 120 + def mail_syncing() -> bool: 121 + try: 122 + out = subprocess.check_output(["pgrep", "-x", "mbsync"], text=True, stderr=subprocess.DEVNULL) 123 + return bool(out.strip()) 124 + except subprocess.CalledProcessError: 125 + return False 126 + 127 + 128 + def mail_sync_spawn(channel: str | None): 129 + """Start mbsync (for channel or all) + mu index in the background.""" 130 + MAILDIR.mkdir(parents=True, exist_ok=True) 131 + target = channel if channel else "-a" 132 + cmd = f'exec {MBSYNC_BIN} {target} >> "{MAIL_SYNC_LOG}" 2>&1 && {MU_BIN} index --quiet >> "{MAIL_SYNC_LOG}" 2>&1' 133 + subprocess.Popen(["/bin/sh", "-c", cmd], stdin=subprocess.DEVNULL, 134 + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, 135 + start_new_session=True) 136 + 137 + 97 138 def open_ssh(host: str): 98 139 """Open a Terminal.app window and run `ssh <host>`.""" 99 140 subprocess.run([ ··· 112 153 self.tailnet_item = rumps.MenuItem("Tailnet: —") 113 154 # Seed a child so rumps creates the underlying NSMenu; refresh_tailnet() will replace it. 114 155 self.tailnet_item.add(rumps.MenuItem("…")) 156 + self.mail_item = rumps.MenuItem("Mail: —") 157 + self.mail_item.add(rumps.MenuItem("Sync both", callback=lambda _: mail_sync_spawn(None))) 158 + self.mail_item.add(rumps.MenuItem("Sync ac-mail", callback=lambda _: mail_sync_spawn("ac-mail"))) 159 + self.mail_item.add(rumps.MenuItem("Sync jas-mail", callback=lambda _: mail_sync_spawn("jas-mail"))) 160 + self.mail_item.add(rumps.MenuItem("Open sync log", callback=self.open_mail_log)) 161 + self._mail_tick = 0 115 162 self.awake_item = rumps.MenuItem( 116 163 "Stay awake (lid closed)", callback=self.toggle_awake 117 164 ) ··· 123 170 self.subs_item, 124 171 None, 125 172 self.tailnet_item, 173 + self.mail_item, 126 174 None, 127 175 self.awake_item, 128 176 rumps.MenuItem("Sleep now", callback=self.sleep_now), ··· 165 213 self.subs_item.title = f"Subagents in flight: {subs}" 166 214 self.awake_item.state = 1 if sd else 0 167 215 self.refresh_tailnet() 216 + self.refresh_mail() 168 217 169 218 def refresh_tailnet(self): 170 219 peers = tailscale_peers() ··· 190 239 self.tailnet_item.add( 191 240 rumps.MenuItem(label, callback=lambda _, h=host: open_ssh(h)) 192 241 ) 242 + 243 + def refresh_mail(self): 244 + # Ticks every 2s; only poll mu every ~30s (15 ticks) unless a sync is running. 245 + syncing = mail_syncing() 246 + self._mail_tick = (self._mail_tick + 1) % 15 247 + if self._mail_tick != 1 and not syncing and self.mail_item.title != "Mail: —": 248 + # Still keep the syncing suffix accurate without re-querying mu. 249 + return 250 + unread = mail_unread_count() 251 + if unread is None: 252 + base = "Mail: unavailable" 253 + elif unread == 0: 254 + base = "Mail: inbox zero" 255 + else: 256 + base = f"Mail: {unread} unread" 257 + self.mail_item.title = f"{base} · syncing…" if syncing else base 258 + 259 + def open_mail_log(self, _): 260 + if MAIL_SYNC_LOG.exists(): 261 + subprocess.run(["open", "-a", "Console", str(MAIL_SYNC_LOG)], check=False) 262 + else: 263 + rumps.notification("slab", "Mail sync", "No sync log yet.") 193 264 194 265 def toggle_awake(self, sender): 195 266 cmd = "auto" if sender.state else "awake"