Open-Source Cold Email & Warmup Platform
Warmbly is the open-source platform for cold email: warm your mailboxes, send campaigns, and triage every reply. Run it on our cloud or self-host the whole thing.
Schedule For You is a social media scheduler: write one post, tailor it per platform and publish to X, Bluesky, Instagram, LinkedIn, Threads, TikTok, YouTube, Facebook, Pinterest and Google Business f
MarketingJavaScript · TypeScriptGitHub @shaheertigerOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Schedule For You is a social media scheduler: write one post, tailor it per platform and publish to X, Bluesky, Instagram, LinkedIn, Threads, TikTok, YouTube, Facebook, Pinterest and Google Business from one queue. Free for 3 accounts.
Taken from the repository README
Loading the full README.
Read the full README ↗Post scheduler: write once, publish to ten social platforms from one queue. Next.js 16 on Vercel Pro, Neon Postgres for data, Neon Auth for sign-in, Vercel Cron for publishing, Neon Object Storage for media.
| Route | What it is |
|---|---|
/ | Landing page, server-rendered. Phones get the mobile design (LandingMobile, chosen by user agent, then kept in step with the viewport); /?desktop=1 shows the full one |
/signup, /login | Email + password accounts (Neon Auth) |
/app | The dashboard on your real data (signed in) |
/demo | The same dashboard with sample data, nothing saved |
/app/automations | Signature, RSS auto-post, webhooks (workspace menu → Automations) |
/p/<token> | Client preview link: view a post, approve, ask for changes or comment (no account) |
/c/<token> | Shared calendar: read-only schedule for clients and teammates; /c/<token>/feed.ics is the iCal feed |
/brand | Logo sheet |
/api/v1/* | REST API (session or Authorization: Bearer sfy_… API key) |
/api/cron/publish | Publishing job, run by Vercel Cron every minute |
On phones the dashboard switches to a top bar, a bottom tab bar and a slide-in menu.
/api/cron/publish (with CRON_SECRET). It claims due posts with FOR UPDATE SKIP LOCKED, so overlapping runs never post twice.lib/server/adapters). Media is read from the storage bucket. Tokens are refreshed when close to expiry."Publish now" in the dashboard publishes immediately in the request instead of waiting for the cron.
Neon: in the Vercel project, add the Neon integration (Storage → Neon). It sets DATABASE_URL and DATABASE_URL_UNPOOLED. Migrations run automatically on every build (scripts/migrate.mjs).
Neon Auth (sign-in): neon.ts declares auth: true. Add NEON_AUTH_BASE_URL (the branch's Auth URL from the Neon Console → Auth, e.g. https://ep-….neonauth.c-6.us-east-2.aws.neon.tech/neondb/auth) and NEON_AUTH_COOKIE_SECRET (openssl rand -base64 32). Neon Auth only accepts sign-ins from trusted domains: add your production domain (and any preview domain you use) in the Neon Console → Auth → Domains, or with neon neon-auth domain add https://your-domain.com. Localhost is allowed by default.
Neon Auth stores users, passwords and sessions in the neon_auth schema. The app's users table keeps a profile row with the same id, created on first sign-in together with the user's first workspace. Workspaces, team invites and API keys work as before. Before launch, turn on branded auth emails (see Branded auth emails); Neon's shared sender is for development.
Secrets: add TOKEN_ENCRYPTION_KEY (openssl rand -base64 32; don't change it later or saved platform logins stop working) and CRON_SECRET (any long random string).
Media storage (Neon Object Storage): the private bucket sfy-media is declared in neon.ts (neon deploy creates it). In the Neon Console, open Connect → Storage and add the values to Vercel under these names (Vercel reserves the AWS_* names Neon shows): STORAGE_ENDPOINT (= AWS_ENDPOINT_URL_S3), STORAGE_ACCESS_KEY_ID (= AWS_ACCESS_KEY_ID), STORAGE_SECRET_ACCESS_KEY (= AWS_SECRET_ACCESS_KEY), STORAGE_REGION (= AWS_REGION). Set STORAGE_BUCKET only if you use a different bucket name. Browsers upload with presigned URLs, so the bucket needs this CORS rule (already set on the production branch):
[{ "AllowedOrigins": ["*"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 }]
All variables are listed in .env.example.
By default Neon Auth sends password reset and confirmation emails itself, from [email protected], with Neon's branding and a link to Neon's domain. The webhook at /api/webhooks/neon-auth replaces them with our own emails, sent through Resend from EMAIL_FROM. The reset email links straight to APP_URL/reset-password?token=….
RESEND_API_KEY, EMAIL_FROM, APP_URL and NEON_AUTH_BASE_URL are set in Vercel and deployed (the webhook checks Neon's signature with the public keys at NEON_AUTH_BASE_URL/.well-known/jwks.json; no secret to copy).https://scheduleforyou.com/api/webhooks/neon-authsend.magic_link only.Schedule For You (it defaults to the Neon project name and is shown wherever Neon still names the app)./forgot-password, request a reset for your own address. The email should come from EMAIL_FROM, say "Reset your Schedule For You password" and link to scheduleforyou.com/reset-password?token=…. Vercel logs show [neon-auth webhook] sent forget-password email.Same settings by CLI: neon neon-auth config webhook update --enabled --url https://scheduleforyou.com/api/webhooks/neon-auth --enabled-events send.magic_link --timeout 10.
Once send.magic_link is subscribed, Neon stops sending these emails itself, so the webhook must work: if Resend isn't configured or a send fails, the webhook answers 5xx, Neon retries twice (15 seconds in total), and then the user sees an error. To go back to Neon's emails, turn the webhook off. Each event is sent once (event ids are kept in neon_auth_events); bad signatures and requests older than 5 minutes get 401. Each Neon branch has its own webhook setting, so preview branches keep Neon's default emails unless you set one up there too.
Every OAuth callback URL is APP_URL/api/connect/<platform>/callback.
| Platform | Env keys | Notes |
|---|---|---|
| Bluesky | none | Users connect with their handle and an app password. |
| X | X_CLIENT_ID, X_CLIENT_SECRET | developer.x.com, OAuth 2.0 confidential client; scopes tweet.write offline.access media.write. |
| Facebook Pages | FACEBOOK_APP_ID, FACEBOOK_APP_SECRET (+ optional FACEBOOK_CONFIG_ID) | Needs pages_manage_posts; App Review to post for other people's Pages. Set FACEBOOK_CONFIG_ID only if your app uses a Facebook Login for Business configuration; it then replaces the scope list. |
| same Meta app as Facebook | Business/Creator account linked to a Page; media by public URL. Asks for instagram_basic, instagram_content_publish, pages_show_list, pages_read_engagement, business_management. | |
| Threads | THREADS_APP_ID, THREADS_APP_SECRET | Separate Threads app in Meta developers. |
LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET | Products "Share on LinkedIn" + "Sign In with LinkedIn using OpenID Connect". | |
| TikTok | TIKTOK_CLIENT_KEY, TIKTOK_CLIENT_SECRET | Content Posting API; posts are private until the app passes audit. |
| YouTube | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | YouTube Data API v3; unverified apps upload as private. |
| Google Business | same Google client | Business Profile API quota approval required. |
PINTEREST_APP_ID, PINTEREST_APP_SECRET | Trial access posts only to your own account. |
The adapters were ported from the Fanout codebase and follow each platform's public API docs, but they have only been run in dry-run mode so far. Watch the Vercel logs ([publish] … failed) on the first real post to each platform.
npm install
cp .env.example .env.local # fill in DATABASE_URL, NEON_AUTH_*, TOKEN_ENCRYPTION_KEY, CRON_SECRET (storage optional)
npm run db:migrate
npm run dev # http://localhost:3000
Set SFY_DRY_RUN=1 to "publish" without calling any platform. To run the publisher by hand:
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/publish
npm run typecheck # tsc over the app, server code and tests
npm test # unit tests (node:test) for the pure server logic: post bodies, URL safety, billing access, signatures, webhooks, RSS templates
npm run check # both
npm run build # production build; migrations are skipped when DATABASE_URL is unset
.github/workflows/ci.yml runs all of these on every push and pull request. Tests live in tests/ and load the TypeScript server modules directly (Node 22 strips the types; tests/hooks.mjs resolves extensionless imports and stubs server-only).
Create a key in Settings → API keys, then:
curl -H "Authorization: Bearer sfy_..." https://your-domain.com/api/v1/accounts
curl -H "Authorization: Bearer sfy_..." -H "Content-Type: application/json" \
-d '{"caption":"Hello","account_ids":["acc_..."],"scheduled_at":"2026-11-01T14:00:00Z"}' \
https://your-domain.com/api/v1/posts
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/accounts | Connected accounts |
| GET | /api/v1/platforms | Limits and whether each platform is configured |
| GET | /api/v1/posts?status=&from=&to=&limit= | List posts |
| POST | /api/v1/posts | { caption, account_ids | targets:[{account_id, caption?, options?}], media?:[id], scheduled_at?, publish_now?, status? }; 422 with errors[] if a platform rule fails |
| GET / PATCH / DELETE | /api/v1/posts/:id | |
| POST | /api/v1/posts/:id/publish | Publish now or retry failed targets |
| GET / POST | /api/v1/media | POST {filename, mime, size, folder?} returns upload_url; PUT the file there, then PATCH /api/v1/media/:id {uploaded:true} |
| PATCH / DELETE | /api/v1/media/:id | Confirm upload, move folder, delete |
| GET | /api/v1/keys | Keys can only be created or revoked from the dashboard |
| POST | /api/v1/posts/:id/share | Client preview link { url, expires_at } (30 days; reused while valid) |
| GET | /api/v1/posts/:id/comments | Comments, approvals and change requests left on the preview link |
| PUT | /api/v1/signature | { signature } added to every post when it publishes; "" turns it off. Per post: signature: false |
| GET / POST | /api/v1/webhooks | POST { url, events?: ["post.published","post.partial","post.failed"] }; the secret is returned once |
| PATCH / DELETE | /api/v1/webhooks/:id | { active?, events? } |
| POST | /api/v1/webhooks/:id/test | Sends a ping event |
| GET / POST |
Owners, editors and API keys can change signature, webhooks and feeds; members can only read them.
options.first_comment on a target). It is posted by the same account right after the post goes live, which is where hashtags, links and calls to action usually go. A comment that fails never fails the post: the target stays published and its error says what happened.--- is published as a thread. Each part becomes a reply to the one before, media goes on the first post, and each part has to fit the platform's limit (the composer counter shows "3-post thread · longest 262/280"). A run that is cut off mid-thread continues from the next part instead of posting the first ones again. Up to 25 parts./l/<code>) as the post publishes, and each visit is counted. Analytics shows real clicks per post; the API returns stats.clicks per target and workspace totals in GET /automations. A link is only swapped when the short link is not longer than it, so a post can never grow past a character limit. Migration 011_links.sql.- (API: first_comment: false). PATCH /api/v1/accounts/{id}.format can be slack or discord, so an incoming webhook URL from either works as the endpoint and you get a readable message ("✅ Published: … • X @harbor → link") instead of raw JSON. Migration 012_webhook_format.sql.reuseCooldownDays) marks files posted within the window as used recently; the composer says so when you add one, and never blocks it. Usage comes from lib/server/media-usage.ts (posts × targets × accounts) and the shared rules in lib/media-usage.js; the API returns it as usage on GET /api/v1/media.thumb_url) instead of the original, which can be 20 MB. The thumbnail URL goes through Next's image optimizer, which reads the original once from /m/<id> (a stable, cacheable route for uploaded images; videos and GIFs are never served there) and caches the resized result at the edge. The preview dialog layers the full image over the thumbnail so it is never blank. Videos get a JPEG poster still made in the browser at upload (poster_size / poster: true on the media API, stored as poster_key, migration 015_media_poster.sql), so cards show the still instead of loading the video; older videos get one backfilled on first dashboard load when the bucket allows cross-origin reads. The preview dialog plays videos with controls.GET/POST/DELETE /api/v1/calendar-link. Migration 013_calendar_link.sql.utm_source=<platform>, utm_medium=social and utm_campaign=<first label> for attribution in Google Analytics and similar tools. Tags a link already has are kept./r/<code>, shown under Automations with copy, X, Bluesky, LinkedIn and email buttons. Opening it sets a 30-day cookie and lands on /signup; the workspace created there records referred_by and gets a 14-day trial instead of 7 (REFERRED_TRIAL_DAYS in lib/server/billing.ts, used by Checkout). The first time that workspace's subscription is active, the Stripe webhook credits the sender's customer balance with one month of their plan (rewardReferrer in lib/server/referrals.ts). The reward is claimed with one atomic update, so a replayed webhook never pays twice, and a Stripe failure releases the claim so the retry can. Your own link on your own account does nothing.app/manifest.js), so Android and desktop Chrome and Edge list it in the Share menu; a bookmarklet (Automations → Share to queue) does the same from any browser, with the page title, selected text and address. Both open /app?share=1&title=…&text=…&url=…, which the dashboard turns into a new post (lib/share-caption.js). Signed-out visitors keep the query through the login redirect (proxy.ts).{title}, {link}, {description} (HTML stripped, 280 characters).X-SFY-Event and X-SFY-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">. One attempt, 5-second timeout; the last result shows in Automations.https:// addresses; private and internal addresses are refused, and each redirect is checked. For local testing only, ALLOW_PRIVATE_URLS=1 lifts this.{ "mcpServers": { "scheduleforyou": { "command": "node", "args": ["/path/to/sfu/mcp/server.mjs"],
"env": { "SFY_URL": "https://your-domain.com", "SFY_API_KEY": "sfy_..." } } } }
Tools: list_accounts, create_post, list_posts, publish_post, delete_post.
components/DashboardView.jsx, LandingView.jsx, LandingMobileView.jsx, BrandView.jsx, Dashboard.jsx, Landing.jsx, LandingMobile.jsx, Brand.jsx: generated from design/*.dc.html by npm run design:convert. Don't hand-edit; change the design file and regenerate.components/LiveDashboard.jsx: runs the generated dashboard on real data. It loads /api/app/state, saves changes (posts, settings, media folders, accounts) and swaps the demo actions for real ones.lib/server/: database, auth, encryption, media storage, posts, publisher and platform adapters.db/migrations/: SQL migrations, applied in order on each build.Every public page has a markdown version, built from the same content files and data the HTML uses (never scraped from the rendered page):
GET /<page>.md (/index.md for the homepage), GET /<page> with Accept: text/markdown (only when its q-value beats text/html; */* and browsers get HTML), or GET /api/md/<page> directly. proxy.ts rewrites the first two to the one handler in app/api/md/[[...path]]/route.js.text/markdown with X-Markdown-Tokens, X-Robots-Tag: noindex, follow, Content-Location and a canonical Link to the HTML page, Vary: Accept, CORS and cache headers; HEAD and OPTIONS work. Unknown or private paths get a markdown 404.lib/md/registry.js is the single list of renderers (ROUTES) and the skip list with reasons (SKIPPED). To add a page, add one entry there: { path: '/new-page', group: 'core', name: 'New page', render: () => ({ title, summary, updated?, body }) }, or a pattern with list() for dynamic pages. llms.txt, llms-full.txt and the catalog pick it up; tests/markdown-site.test.ts fails when a page.jsx has neither a renderer nor a skip reason./llms.txt (llmstxt.org map, capped lists with links to the full ones), /llms-full.txt (every page concatenated, large collections capped), /ai.txt (identity, permissions, restrictions, attribution, scope), /api/md/_catalog (JSON of every route) and /.well-known/mcp.json (discovery for the MCP server at /api/mcp). Shared facts live in lib/seo.js and lib/md/identity.js.<link rel="alternate" type="text/markdown"> and carries <meta name="ai-summary"> (hand-written for home, pricing, blog, tools and docs in lib/seo-pages.js, the description elsewhere). The HTML response of a page with a twin carries Link: <….md>; rel="alternate"; type="text/markdown" plus rel="describedby" links to the three txt files, Vary: Accept and X-AEO-Version (set in proxy.ts). Clients from the AEO spec's AI agent registry (GPTBot, ClaudeBot, PerplexityBot and the rest in lib/md/negotiate.js) get the markdown twin by User-Agent; an Accept header that rules out both HTML and markdown gets 406. robots.txt (app/robots.txt/route.js, a route handler so it can carry comments) allows the AI search crawlers by name and, by choice, the training crawlers too, and points at llms.txt and ai.txt; URLs stay out of the sitemap.What Google and Bing get, and where it lives:
app/page.jsx): the full HTML for both the desktop and the phone design, picked by user agent.lib/seo.js, rendered by components/JsonLd.jsx): Organization, WebSite and SoftwareApplication (with the Solo and Studio prices) on /, plus FAQPage on the phone design, which is the one that shows the FAQ. A test checks the prices against lib/server/billing.ts and the FAQ against the landing component, so the markup can never drift from what the page shows.app/opengraph-image.jsx), a web manifest and PNG icons (public/brand/, app/apple-icon.png; regenerate from the SVG glyph if the logo changes).robots.txt and sitemap.xml (app/robots.js, app/sitemap.js) list / and /signup; the dashboard, API, preview links, invites, sign-in and the logo sheet are noindex. API responses also send X-Robots-Tag: noindex.GOOGLE_SITE_VERIFICATION (Search Console → HTML tag) and BING_SITE_VERIFICATION (Bing Webmaster Tools → meta tag) and the tags are rendered. Bing can also import the Search Console property directly.INDEXNOW_KEY (openssl rand -hex 16), deploy, then run npm run seo:indexnow after changing public pages so Bing recrawls within minutes. The key is served at /indexnow-key.txt.After the first deploy: submit https://scheduleforyou.com/sitemap.xml in Search Console and Bing Webmaster Tools, and check / with Google's Rich Results Test and Bing's URL Inspection.
public/fonts with font-display: optional and preloaded in app/layout.jsx: text is laid out in its final font on first paint, and on a slow first visit the fallback font is kept for that page view rather than swapped in later. node scripts/fonts.mjs refreshes the files and app/fonts.css.html { scrollbar-gutter: stable } keeps the page from shifting sideways when a short view loses its scrollbar, and every dashboard screen keeps a minimum height so switching screens or tabs never collapses the page (app/globals.css).Origin matching the site's host; other origins get 403. API-key requests are exempt (a Bearer token can't be forged by another site).next.config.mjs): HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, a strict referrer policy, a locked-down Permissions-Policy, and Cache-Control: no-store on every API response.HttpError) are returned; database and network errors are logged and answered with a generic message.lib/server/posts.ts → PostBody); malformed input answers 400 with the first problem named.TOKEN_ENCRYPTION_KEY./app, /api, /p and /invite are excluded in robots.txt.Instagram, Threads, TikTok photos and Google Business fetch media by URL; they get 24-hour signed URLs. To serve media from a CDN instead, set STORAGE_PUBLIC_URL. Locally, Neon's own AWS_* names also work. Cloudflare R2 still works: leave the storage variables unset and set R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET.
Domain: set APP_URL to your production URL (used for OAuth callbacks and invite links).
Cron: vercel.json already schedules /api/cron/publish every minute. Every-minute crons need the Pro plan. The same run sends reminder and digest emails.
Email (Resend): verify your domain in Resend (Domains), then add RESEND_API_KEY and EMAIL_FROM (an address on that domain, e.g. Schedule For You <[email protected]>). The app sends:
RESEND_API_KEY these are skipped and logged. Password reset, email confirmation and sign-in link emails also go through Resend once the Neon Auth webhook is set up (see Branded auth emails).Billing (Stripe): three plans, one per workspace. Free (no card): 3 connected accounts, up to 10 scheduled posts waiting at a time, and a "Scheduled with Schedule For You" link added to posts when it fits. Solo $19/mo (15 accounts) and Studio $59/mo (100 accounts, team members) lift the limits and unlock the paid-only features. Every workspace starts on Free; the paid plans' 7-day free trial needs a card: the owner picks a plan in Stripe Checkout, Stripe runs the trial and charges on day 8 unless they cancel. One free trial per workspace.
lib/plan-limits.js (shared by the server, dashboard and site) and are enforced in lib/server/billing.ts (isPaid, requirePaid, accountLimitOf) and savePost. Paid-only, refused with a 402 on Free: bulk scheduling and CSV import, API keys and API/MCP requests, webhooks, RSS auto-post, client preview links, approvals, the shared calendar, repeating and evergreen posts, link click tracking. Switching one off or deleting it still works on Free. Webhooks, RSS polling, repeat copies and the shared calendar link stop for Free workspaces./app?upgrade=<feature> opens the picker from elsewhere (the Automations page uses it).STRIPE_SECRET_KEY (Stripe → Developers → API keys). Until it is set, billing is off and nothing is limited.https://scheduleforyou.com/api/stripe/webhook with events checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted. Put its signing secret in STRIPE_WEBHOOK_SECRET.past_due) keep the paid plan while Stripe retries. Team invites need Studio. Migration 016_free_plan.sql moved existing unpaid workspaces to Free.UPDATE workspaces SET subscription_status='comped', plan='studio', account_limit=100 WHERE id='…';Platforms: add the developer keys for each platform you want (see below). Bluesky needs none.
/api/v1/feeds |
POST { url, mode: "draft" | "queue", template?, account_ids? }; items already in the feed are skipped |
| PATCH / DELETE | /api/v1/feeds/:id | { active?, mode?, template?, account_ids? } |
| POST | /api/v1/feeds/:id/check | Check now instead of waiting for the 15-minute run |
/m/<id> copy), uploaded as a JPG next to the original and swapped into the draft; the composer shows one "Converting…" line instead of one error per Instagram account, and names the file if a conversion fails..md