<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: foxck016077</title>
    <description>The latest articles on DEV Community by foxck016077 (@foxck016077).</description>
    <link>https://dev.to/foxck016077</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3934070%2F719d233b-fccf-473b-ab80-941f00a2de88.png</url>
      <title>DEV Community: foxck016077</title>
      <link>https://dev.to/foxck016077</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/foxck016077"/>
    <language>en</language>
    <item>
      <title>Gmail OAuth client_id is not a secret â design notes for self-host Actors</title>
      <dc:creator>foxck016077</dc:creator>
      <pubDate>Sat, 16 May 2026 03:20:01 +0000</pubDate>
      <link>https://dev.to/foxck016077/gmail-oauth-clientid-is-not-a-secret-a-design-notes-for-self-host-actors-19af</link>
      <guid>https://dev.to/foxck016077/gmail-oauth-clientid-is-not-a-secret-a-design-notes-for-self-host-actors-19af</guid>
      <description>&lt;p&gt;This question keeps coming up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"My Gmail OAuth &lt;code&gt;client_id&lt;/code&gt; got leaked. Is the system compromised?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Short answer: &lt;code&gt;client_id&lt;/code&gt; was never a secret. What you actually need to protect is the &lt;strong&gt;token&lt;/strong&gt;, the &lt;code&gt;client_secret&lt;/code&gt; (if your flow uses one), and the overall authorization exchange boundary.&lt;/p&gt;

&lt;p&gt;This matters more in a self-host Actor scenario, because you are not shipping a single deployment â€” every user runs it their own way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Public identifier, not sensitive credential
&lt;/h2&gt;

&lt;p&gt;The OAuth &lt;code&gt;client_id&lt;/code&gt; is closer to an application identifier than a password. It needs to be visible to the OAuth server and the front-end flow. In many scenarios it appears in places that are reasonable to see.&lt;/p&gt;

&lt;p&gt;Treating &lt;code&gt;client_id&lt;/code&gt; like a password and "hiding" it is usually a misunderstanding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You cannot hide it (front-end, request URLs, logs all expose it)&lt;/li&gt;
&lt;li&gt;Hiding it does not raise overall security&lt;/li&gt;
&lt;li&gt;Worse, it can distract you from the real attack surface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question worth asking is: &lt;strong&gt;if an attacker knows your &lt;code&gt;client_id&lt;/code&gt;, what can they still do?&lt;/strong&gt; If the answer is "nothing â€” they cannot complete an authorization exchange or obtain a valid token", your design is pointing in the right direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the security budget actually goes
&lt;/h2&gt;

&lt;p&gt;For a self-host Actor, security is about flow integrity, not a single magical value. I focus on four layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;redirect URI allowlist&lt;/strong&gt; â€” only registered and verifiable callbacks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;state / anti-CSRF&lt;/strong&gt; â€” every authorization round-trip is bound to the originating session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;token storage and rotation&lt;/strong&gt; â€” least privilege, shortest exposure, revocable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tenant isolation&lt;/strong&gt; â€” every token is namespaced to a tenant and never crosses boundaries&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If these four layers are solid, &lt;code&gt;client_id&lt;/code&gt; visibility is not a primary risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistake: budget in the wrong place
&lt;/h2&gt;

&lt;p&gt;I have seen projects spend real effort on "obfuscating &lt;code&gt;client_id&lt;/code&gt;" while ignoring problems that actually cause incidents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;weak callback endpoint validation&lt;/li&gt;
&lt;li&gt;tokens accidentally landing in logs with broad ACLs&lt;/li&gt;
&lt;li&gt;error handlers leaking internal state to the caller&lt;/li&gt;
&lt;li&gt;inconsistent multi-tenant key naming that lets one tenant trip into another's data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are the things that bite.&lt;/p&gt;

&lt;p&gt;Security is not about mystique. It is about staying recoverable in the worst case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for multi-tenant Actor design
&lt;/h2&gt;

&lt;p&gt;Once you accept "&lt;code&gt;client_id&lt;/code&gt; is visible by design", the architecture gets cleaner. Attention naturally shifts to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;scope minimization&lt;/strong&gt; â€” request only the permissions you truly need (in my Actor, &lt;code&gt;gmail.readonly&lt;/code&gt; and nothing else)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;explicit token lifecycle&lt;/strong&gt; â€” when it renews, when it expires, when it can be revoked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;auditable execution path&lt;/strong&gt; â€” which tenant triggered which run when&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These decisions directly affect product trust and how much firefighting future-you will be doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-host does not exempt you from threat modeling
&lt;/h2&gt;

&lt;p&gt;A tempting shortcut: "It's self-host, so the risk lives with the user."&lt;/p&gt;

&lt;p&gt;That is half right. Yes, deployment responsibility moves to the operator. But as the author you still owe &lt;strong&gt;secure defaults&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;safe defaults rather than risky defaults&lt;/li&gt;
&lt;li&gt;explicit documentation rather than verbal hints&lt;/li&gt;
&lt;li&gt;observable errors rather than silent failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise you are not reducing risk, you are just shifting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation pattern
&lt;/h2&gt;

&lt;p&gt;For OAuth-touching repos, I now write three things first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which values are public, which must stay private&lt;/li&gt;
&lt;li&gt;The lifecycle and revocation path for every token&lt;/li&gt;
&lt;li&gt;What a user can actually do when an authorization error happens&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;new users do not get blocked on a panic about the wrong thing&lt;/li&gt;
&lt;li&gt;experienced reviewers can audit your security logic quickly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The clearer you are, the easier it is for the community to trust your project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open-source carries extra weight
&lt;/h2&gt;

&lt;p&gt;In open source, a misleading security narrative is more dangerous than in a private product, because it gets copied.&lt;/p&gt;

&lt;p&gt;If you frame &lt;code&gt;client_id&lt;/code&gt; as "top secret", others will copy that posture and ship the same broken model with real problems intact.&lt;/p&gt;

&lt;p&gt;I prefer to spell it out in the README:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;client_id&lt;/code&gt; is expected to be visible&lt;/li&gt;
&lt;li&gt;the real sensitive surface is token handling and flow protection&lt;/li&gt;
&lt;li&gt;multi-tenant isolation is enforced through data structure and routing logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That way, even a fork carries a less-wrong threat model forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing: stop asking "can I hide it"
&lt;/h2&gt;

&lt;p&gt;The question I keep on a sticky note for OAuth design is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"If this value gets seen, is the system still safe?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a single exposed value collapses the system, the problem is not secret management â€” it is brittle architecture.&lt;/p&gt;

&lt;p&gt;For Gmail OAuth in a self-host Actor, &lt;code&gt;client_id&lt;/code&gt; is not the protagonist. The real protagonist is a verifiable, revocable, isolated authorization system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;https://github.com/foxck016077/apify-gmail-inbox-intel&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;li&gt;If you work on long-running agents and workflow systems, the related theme â€” namespacing state, permission, and context boundaries to keep an expanding system controllable â€” is something I think about a lot. Toolkit page: &lt;a href="https://foxck.gumroad.com" rel="noopener noreferrer"&gt;https://foxck.gumroad.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>google</category>
      <category>security</category>
    </item>
    <item>
      <title>An Apify Actor for Gmail inbox analytics â refresh-token-only OAuth, async router, per-feature quota</title>
      <dc:creator>foxck016077</dc:creator>
      <pubDate>Sat, 16 May 2026 01:31:10 +0000</pubDate>
      <link>https://dev.to/foxck016077/an-apify-actor-for-gmail-inbox-analytics-a-refresh-token-only-oauth-async-router-per-feature-pi2</link>
      <guid>https://dev.to/foxck016077/an-apify-actor-for-gmail-inbox-analytics-a-refresh-token-only-oauth-async-router-per-feature-pi2</guid>
      <description>&lt;p&gt;I just open-sourced an Apify Actor for Gmail inbox workflow analytics: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;&lt;code&gt;apify-gmail-inbox-intel&lt;/code&gt;&lt;/a&gt;. It is &lt;strong&gt;not a scraper&lt;/strong&gt;, &lt;strong&gt;not a bulk sender&lt;/strong&gt; â€” it is an inbox analytics tool on &lt;code&gt;gmail.readonly&lt;/code&gt; scope. This post is a design tour, not a tutorial.&lt;/p&gt;

&lt;p&gt;If you have ever asked "which client thread did I forget to reply to?" or "what is my average reply turnaround?", this is the kind of workflow it covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an Apify Actor
&lt;/h2&gt;

&lt;p&gt;I needed three things at once: serverless runtime, pay-per-result billing, and a real input schema. Apify gives me all of them without writing a backend. I get a hosted endpoint, dataset storage, a key-value store for state, and a developer audience that is already paying for actors.&lt;/p&gt;

&lt;p&gt;The actor exposes four features through a single entrypoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;thread_search&lt;/code&gt; â€” query Gmail threads by &lt;code&gt;q&lt;/code&gt;, paginate, return metadata + message counts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reply_metrics&lt;/code&gt; â€” for each thread, compute reply-from-me, reply-from-others, last-reply age, SLA breach flag&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;summarizer&lt;/code&gt; â€” optional OpenAI LLM thread summary (BYO API key)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unread_digest&lt;/code&gt; â€” list unread threads in the last N hours, grouped by label&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Design decision 1: refresh-token-only OAuth
&lt;/h2&gt;

&lt;p&gt;The hardest call early on was OAuth. Two paths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;3-legged OAuth on the Actor side&lt;/strong&gt; â€” Actor hosts callback URL, exchanges code, stores tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh-token-only&lt;/strong&gt; â€” user does the OAuth dance once on their own, hands me &lt;code&gt;{refresh_token, client_id, client_secret}&lt;/code&gt; as Actor input.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I picked option 2. Reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apify Actors do not have a stable HTTPS callback URL per user. Each run is a job, not a server.&lt;/li&gt;
&lt;li&gt;"We never store your Gmail tokens" is a far easier privacy story to defend.&lt;/li&gt;
&lt;li&gt;I do not want to be the holder-of-secrets for someone else's mailbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the Actor, the flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/gmail_client.py â€” sketch
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;httpx_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://oauth2.googleapis.com/token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oauth_token&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Access token lives in memory only. Job end â†’ process tears down â†’ token gone. Best effort, but at least nothing persists in Apify storage with my code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision 2: one async router, not four actors
&lt;/h2&gt;

&lt;p&gt;Tempting to split into four actors. I did not, for two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Marketing surface area. One actor with four &lt;code&gt;feature&lt;/code&gt; enum values gets one Store page, one rating, one review pile. Four actors split everything four ways.&lt;/li&gt;
&lt;li&gt;Shared OAuth + shared quota. The token exchange, error handling, mask helpers, KVS quota â€” all reusable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;src/main.py&lt;/code&gt; is just a router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;FEATURES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;thread_search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reply_metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reply_metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarizer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summarizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unread_digest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;actor_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_input&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;feature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;actor_input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;feature&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FEATURES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown feature: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FEATURES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;actor_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each feature module owns its own &lt;code&gt;INPUT_SCHEMA.json&lt;/code&gt; semantics through the same shared file â€” the &lt;code&gt;feature&lt;/code&gt; enum drives validation downstream in each handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision 3: quota lives in Apify KVS
&lt;/h2&gt;

&lt;p&gt;Free tier is 100 threads / month. That counter has to survive across runs. Apify KeyValueStore is the obvious home â€” no extra DB, persistent, scoped to the Actor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/quota.py â€” sketch
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_and_increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;kvs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_key_value_store&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quota/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;month_key&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;kvs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;FREE_LIMIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;QuotaExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FREE_LIMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;kvs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Month roll-over is a string key by year-month â€” no cron, no migration, no drift. Pro tier flips a flag and skips the check entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;Six pytest tests, &lt;code&gt;asyncio_mode = auto&lt;/code&gt; in &lt;code&gt;pytest.ini&lt;/code&gt;. Coverage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Router rejects unknown feature&lt;/li&gt;
&lt;li&gt;Each of 4 features short-circuits cleanly in &lt;code&gt;dry_run=True&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Quota raises after limit, allows under
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[pytest]&lt;/span&gt;
&lt;span class="py"&gt;asyncio_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tiny config line is the difference between "6 tests pass" and "6 tests error: missing event loop". Learned it the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Free: 100 threads / month&lt;/li&gt;
&lt;li&gt;Pro: $19 / month (5000 threads metadata + 100 LLM summaries)&lt;/li&gt;
&lt;li&gt;Pay-per-result add-on: $0.50 / 1,000 thread metadata, $0.005 / summary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apify handles billing. I handle code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhook trigger&lt;/strong&gt; â€” right now &lt;code&gt;unread_digest&lt;/code&gt; runs on demand. A scheduled trigger + Slack/Discord delivery is the obvious next product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label-level rules&lt;/strong&gt; â€” &lt;code&gt;reply_metrics&lt;/code&gt; is global. A per-label SLA matrix would be more useful for sales teams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-account fan-out&lt;/strong&gt; â€” one run, multiple OAuth tokens, one combined dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/foxck016077/apify-gmail-inbox-intel" rel="noopener noreferrer"&gt;https://github.com/foxck016077/apify-gmail-inbox-intel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;License: MIT&lt;/li&gt;
&lt;li&gt;Actor manifest: &lt;code&gt;.actor/actor.json&lt;/code&gt; + &lt;code&gt;INPUT_SCHEMA.json&lt;/code&gt; if you want to fork&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build automation workflows alongside this kind of inbox tooling, I keep a small Gumroad with practical n8n templates (lead auto-responder, content pipeline, competitor monitor): &lt;a href="https://foxck.gumroad.com" rel="noopener noreferrer"&gt;https://foxck.gumroad.com&lt;/a&gt;. Not required, just adjacent.&lt;/p&gt;

&lt;p&gt;Happy to take feedback on the OAuth-only design â€” was there a reason to go full 3-legged that I am missing?&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>opensource</category>
      <category>serverless</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
