Skip to content

Latest commit

 

History

232 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EasyPost Desktop

A cross-platform (Windows/macOS) desktop app for shipping through EasyPost: rate shopping, labels, tracking, address verification, refunds, insurance, pickups, claims, and batch shipping — all against your own EasyPost account.

Product site: easy-post.spencerfields.com

Status. The source is complete and CI-green on Windows and macOS. The two distribution channels are at different stages — see Distribution for exactly what is and is not ready.

Contents

First-time setup

py -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements-dev.txt
.venv\Scripts\python.exe -m app.main

(On macOS/Linux: python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt && .venv/bin/python -m app.main)

On first launch you'll be asked to paste your EasyPost API key(s). They're stored via your OS's native credential vault (Windows Credential Manager / macOS Keychain / Linux Secret Service, via the keyring library) — never written in plain text, never sent anywhere but EasyPost's API.

Use your test key first. A banner at the top of the app always shows which mode (test/production) is active, and any action that spends real money asks for confirmation while in production mode.

Features

  • Address Book — verify and save addresses via EasyPost, with a filterable country dropdown (197 countries, type to narrow the list) that relabels the state/postal fields to match local convention (Province, County, Prefecture, Postal Code, etc.). If EasyPost can't verify an address, you're asked whether to save it anyway rather than being blocked. Saved addresses can be edited in place — EasyPost addresses are immutable, so editing re-verifies as a new address and replaces the old one locally.
  • Create Shipment — shop live carrier rates and buy/save/print labels. Rates are sorted cheapest-first and tagged "Cheapest"/"Fastest", each row carrying a colour-coded carrier chip, and the purchased label is rendered in-app beside the rate list rather than only opening in a browser. International shipments prompt for full customs information (contents type, itemized customs items, signer/certification) before rates can be fetched, since carriers otherwise reject the label purchase outright. Packages can be entered as custom dimensions, saved as a named preset for reuse, or picked from each carrier's real predefined packages (USPS flat rate boxes, FedEx envelopes, etc.), fetched live from EasyPost.
  • Quick price check — rate a route from two postal codes alone, before any address has been saved. Quotes are deliberately not purchasable: carriers need a complete recipient address to issue a label, so the Buy button stays disabled until you switch back to full addresses.
  • Label format and size — choose PNG, PDF, ZPL or EPL and the printed size (4x6, 4x7, 4x8, 4x5, 8.5x11) in Settings, per EasyPost's supported sizes. Only sizes that make sense for the chosen format are offered, and the carrier caveats (UPS defaults to 4x7; LaserShip/OnTrac are ZPL-only) are shown alongside. Applies to single and batch shipments; carriers fix the size at shipment creation, so it never alters labels already bought.
  • HTS Lookup — search the U.S. International Trade Commission's live Harmonized Tariff Schedule database for customs codes, with results cached locally so repeat searches work even if that API is unreachable.
  • Tracking — add tracking numbers, auto-refreshed every 5 minutes by default, or instantly via an opt-in real-time webhook push — see below.
  • History — browse purchased shipments, request refunds, add insurance.
  • Insurance — insure a shipment bought outside EasyPost by tracking code.
  • Pickups — schedule/buy/cancel carrier pickups for purchased shipments.
  • Claims — file and track insurance claims for lost/damaged/stolen packages.
  • Batch Shipments — import recipients from an Excel workbook or CSV, validate, bulk rate + buy, generate combined labels. The downloadable template is an .xlsx with a dropdown of carrier predefined packages (USPS flat-rate boxes, FedEx envelopes, …); a row that names one needs no dimensions. A plain CSV with the same columns is still accepted.
  • Reports — local spend-by-carrier chart, label counts, refund breakdown.
  • Connect AI agents — expose the app to Claude, Cursor, VS Code and other MCP clients. Reading and rate-shopping run freely; anything that spends money becomes a request a person approves in the app. Direct download only; see MCP-SETUP.md.
  • 50 languages — pick one in Settings; restart to apply. Translations are AI-generated (not professionally reviewed) — open an issue if something reads wrong.

Distribution

The app ships through two channels, and they are deliberately not the same build.

Microsoft Store Direct download
Package .msix .dmg (macOS), .exe folder (Windows)
Price Free, plus a one-time $29.99 "Production unlock" add-on $29 one-time, or $149/$349 a year via Paddle
Licence gate Production mode only — test mode is free; unlocked by the Store add-on (read via Windows.Services.Store) Production mode only — test mode is free; unlocked by an Ed25519 licence key
MCP / AI agents On — helper exposed as an App Execution Alias (easypost-mcp.exe) On — helper beside the app
Signing Store re-signs on publish Apple Developer ID + notarization (macOS); Certum Standard Code Signing pending (Windows)
Status 1.0.6.0 in certification — free + Production-unlock add-on + MCP parity Windows signing → Certum Standard Code Signing (pending)

Both differences are compiled in or out by variant flag files under app/resources/, created at package time by CI and never committed:

Flag file Sets Present in
license_required.flag config.LICENSE_REQUIRED Direct download
mcp_supported.flag config.MCP_SUPPORTED Direct download
store_build.flag config.STORE_BUILD Microsoft Store

app/config.py reads these once at import. The Store build omits the licence flag because gating a Store purchase behind a second paid unlock would breach Microsoft's policies — production is instead gated behind the Store "Production unlock" add-on (config.STORE_BUILDapp/core/store_entitlement.py).

The MCP (AI-agent) bridge is enabled in both builds: MCP_SUPPORTED is mcp_supported.flag or STORE_BUILD. The Store build reaches parity without the install-path executable — the MSIX manifest exposes the helper as an App Execution Alias (easypost-mcp.exe), so clients launch it by name, stable across updates and with the package's own identity. Connecting still falls back to the always-works copy-paste snippet if a client's config cannot be written.

Because a missing flag silently disables a paid feature rather than breaking the build, packaging/verify_variant_flags.sh runs in CI after each packaging step and fails the job if the expected flags are not inside the bundle. This is not theoretical: the flags were absent from PyInstaller's datas for several releases, so LICENSE_REQUIRED was False in every shipped build and the paid app went out ungated. The spec now adds them conditionally and the verifier is the backstop.

Why direct download exists at all

Selling a licence through an app store costs 15–30% in commission. Selling the same licence directly, with Paddle as Merchant of Record, costs roughly 5% + 50c and keeps Paddle responsible for VAT/GST registration and remittance worldwide. Apple's Guideline 3.1.1 forbids unlocking App Store app functionality with an externally-bought key, which is why the licence gate is scoped strictly to builds distributed outside any store.

Publishing the product site

site/ is served from shared cPanel hosting at /home2/spencgh6/easy-post.spencerfields.com. Publishing is fully scripted:

python packaging/deploy_site.py download.html   # one file
python packaging/deploy_site.py --all           # everything in site/

No browser and no logged-in session are needed. A cPanel API token authenticates the call:

Authorization: cpanel spencgh6:<token>
POST https://box5192.bluehost.com:2083/execute/Fileman/save_file_content
     dir=/home2/spencgh6/easy-post.spencerfields.com
     file=<name>  content=<utf-8>  from_charset=UTF-8  to_charset=UTF-8

The token is not in this repository, which is public. It lives in the OS credential store under service cpanel-easypost-site, account spencgh6, and CPANEL_API_TOKEN overrides it. Manage tokens in cPanel under Security -> Manage API Tokens; a leaked one grants full file-manager access to the hosting account and should be revoked there.

Three things that will otherwise cost an hour each:

  • UAPI answers HTTP 200 on failure, with status: 0 and the real reason in errors. The HTTP code alone proves nothing about whether the write landed.
  • The host serves text/html with no charset, so requests decodes the page as ISO-8859-1 and every non-ASCII character reads back as mojibake. That looks exactly like upload corruption and is not — decode as UTF-8 before comparing. Its mod_security also answers 406 to the default python-requests user agent, so the read-back must present a browser one.
  • save_file_content shifts whitespace between tags, so the served byte count will not match the repository exactly. Compare with whitespace removed.

deploy_site.py handles all three and verifies every upload by fetching the file back over HTTPS. Do that verification however you publish: the File Manager listing shows what is on disk, not what the web server actually serves, and only the second one matters.

The pricing page's Buy buttons are progressive enhancement: each is an <a> whose href is a mailto, and site/checkout.js upgrades the click to a Paddle overlay only once Paddle.js has loaded and a client token is present. No JavaScript, a blocked script, an unreachable CDN or a missing token all fall through to the mailto. There is deliberately no state in which a buyer clicks Buy and nothing happens.

checkout.js carries Paddle's client-side token, which is public and meant to be embedded. The API key must never appear there.

Two Paddle account settings gate checkout, and neither is visible from the code: the checkout domain must be approved, and a default payment link must be set. Without the latter, Paddle.Checkout.open and API transaction creation both fail while the catalogue, webhook, domain approval and PricePreview all keep working — the browser only ever says "Something went wrong". Creating a transaction through the Paddle API returns the real error in one call; start there.

Licensing (direct downloads only)

Free in test mode; a licence unlocks production. The app runs with no licence in EasyPost test mode (no real carrier charges), so it can be tried in full for free with a test API key. Switching to production — real labels, real money — requires an activated licence. The gate keys off a key's true mode, not the field it was typed into: keys are verified against EasyPost when entered (app/core/easypost_keys.py) so a production key cannot be run for free by pasting it into the test field, and client.get_client() refuses a production client without a licence as a backstop (app/core/client.py). Being open source, this is honest-by-default rather than hardened DRM — the official builds enforce it and the casual "wrong field" bypass is closed.

Four tiers. Personal is bought once and never expires; the two middle tiers renew annually.

Tier Price Computers Billing
Personal $29 3 one-time, perpetual
Business $149/yr 10 annual subscription
Organisation $349/yr 30 annual subscription
Enterprise contact uncapped (seats: 0) as agreed

Licence keys are Ed25519-signed and verified offline:

EPD1.<base64url(payload)>.<base64url(signature)>
payload = {"v":2,"product":"easypost-desktop","tier":…,"seats":…,"plan":…,"email":…,"order":…,"iat":…}

v1 keys predate tiers, are already in customers' hands, and still verify — read as personal/3 seats. An explicit seats beats the tier table, so a bespoke allowance can be sold without shipping a new build. All three fields are signed, so none can be edited.

There is deliberately no expiry in the key. A key that expired would have to be reissued and re-pasted every year. Instead an annual key is permanent and names the subscription; the activation receipt carries the date and renews itself in the background. A customer pastes one key, once, ever.

  • app/core/license.py holds the public key and verifies. The private key never ships.
  • tools/issue_license.py mints keys by hand — the manual fallback, and how refunds/replacements get handled.
  • server/paddle-license-webhook-worker/ is a Cloudflare Worker that turns a completed Paddle transaction into an emailed key automatically. It verifies Paddle's HMAC signature, checks the price id, mints, and sends via Resend. Deployed and live; see its own README for the four secrets it needs.

Because iat is taken from the Paddle event's occurred_at, a webhook retry mints a byte-identical key rather than a second one.

Seat activation

Counting computers needs a server the app previously did without, so the design is about keeping that intrusion small. The README used to claim the app never phones home. That is no longer true, and PRIVACY.md has been corrected — it now states exactly what activation sends.

  • The network is touched rarely. Activation asks for an Ed25519-signed receipt and verifies it offline on every launch afterwards. No heartbeat, nothing that can fail at start-up. A perpetual licence gets a 400-day receipt and then never contacts us again in practice; an annual one gets a receipt running to the end of the paid period plus 10 days, refreshed quietly 21 days before it lapses so nobody offline on their renewal date is locked out.
  • The server never learns which computer it is. It receives HMAC-SHA256(licence_key, machine_id). Keying by the licence means one computer under two licences produces two unrelated hashes, so activations cannot be correlated across customers.
  • Possession is proved, not asserted. Every request carries an HMAC over its own fields. Knowing an order id is not enough to burn a stranger's seats.
  • Our outage is not their problem. Unreachable, or any 5xx, grants a 14-day grace and retries in the background rather than refusing to start.
  • Moving computers is expected. A full licence returns its device list so the user can release one; a computer silent for 180 days is reclaimed.

The Worker verifies the licence signature rather than consulting a list of known orders, which is what makes complimentary keys work: one minted by hand with tools/issue_license.py --tier business activates exactly like a purchased one, with nothing to register first.

tools/issue_license.py --email x@y.com --order COMP-001 --tier organisation
tools/issue_license.py --email x@y.com --order COMP-002 --seats 10   # bespoke

Discount and 100%-off coupon codes are Paddle Discounts — a fully discounted purchase still fires transaction.completed, so the normal flow mints and emails the key with a record against it. Refunds and adjustments revoke the order and free its seats.

State lives in a Cloudflare D1 database (easypost-licenses): devices, revocations, activation_log. packaging/-side code never touches it.

Connecting AI agents (MCP, direct downloads only)

The direct-download build ships a second executable, easypost-mcp, beside the main app: an MCP (Model Context Protocol) server that lets Claude Desktop, Claude Code, Cursor, VS Code, Windsurf or any other MCP client work with your shipping data. Connect AI agents in the sidebar detects installed clients and — after asking — writes the server into their configuration.

Full instructions, including manual setup, are in MCP-SETUP.md.

The safety model

The threat model is prompt injection, not a malicious user: an agent that has read a poisoned tracking note or web page and is now acting on instructions that are not the user's. Fourteen tools are exposed. Ten read or quote and run immediately, because none of them spends money. Four touch money — request_label_purchase, request_pickup_purchase, request_refund, and check_approval — and none of them completes on its own.

  • Approval is out of band. A spending tool files a request and returns an ID. The confirmation is a dialog inside the app. No tool approves anything, so an agent cannot approve its own request.
  • The summary is re-fetched, never repeated. app/services/mcp_verify.py builds the approval card from EasyPost using only the identifiers the agent supplied. Nothing the agent asserted is displayed, and a rate ID that is not attached to the named shipment raises rather than rendering.
  • Ceilings refuse, they do not prompt. check_ceilings() raises before a request is ever created. A prompt shown often enough eventually gets approved by reflex; a limit that refuses cannot be worn down.
  • Text is neutered. Control characters are stripped from every string that reaches the card, so a value cannot forge lines or an "approved" label.
  • Every check runs twice. app/services/mcp_runner.py re-evaluates enabled state, mode, spending permission and ceilings at execution time, so disabling access or lowering a limit invalidates a request already waiting. Requests expire after an hour.

Access is off by default, spending is a second opt-in on top of that, and both ceilings default to conservative values. Every filed request, approval, rejection and refusal lands in a local mcp_audit table. tests/test_mcp_safety.py pins each of the properties above.

Tracking updates: polling vs. real-time webhook push

By default, tracking updates are pulled by polling EasyPost every 5 minutes (or on demand via "Refresh all now") — this always works, no setup required, and stays on regardless of the option below.

For instant push updates instead of waiting on the poll, Settings has an opt-in, off-by-default "Real-time tracking (advanced)" toggle. Turning it on:

  1. Starts a local HTTP server bound to 127.0.0.1 only (never exposed directly on your LAN).
  2. Opens a Cloudflare Quick Tunnel (cloudflared tunnel --url ...) — zero signup, an anonymous https://*.trycloudflare.com URL is what actually makes the local server internet-reachable; the tunnel connects out to it, so the port is never opened on your router/firewall.
  3. Registers (or updates) an EasyPost webhook pointed at that URL, using a locally-generated secret (stored in your OS credential vault) to verify every incoming request's HMAC signature via the SDK's built-in easypost.util.validate_webhook — unsigned or mis-signed requests are rejected with 401 before touching anything.

Requires cloudflared installed and on your PATH — the app deliberately does not auto-download and execute a fetched binary. Install it with:

If cloudflared isn't found, Settings shows the install command instead of silently failing. Since Cloudflare Quick Tunnels are an anonymous, best -effort service (not a guaranteed-uptime product) and the URL changes every time the tunnel restarts, the app re-registers the webhook's URL with EasyPost on every launch while this is enabled. Turning the toggle back off deletes the EasyPost webhook registration for a clean teardown; merely closing the app leaves it registered against a now-dead URL until you either relaunch (which re-points it) or explicitly disable it.

Running tests

.venv\Scripts\python.exe -m pytest tests/ -v

To also run the live end-to-end smoke test against EasyPost's test mode (safe — no real carrier charges):

$env:EASYPOST_TEST_API_KEY = "test_..."
.venv\Scripts\python.exe -m pytest tests/smoke_test.py -v

Building a standalone app

.venv\Scripts\python.exe -m PyInstaller packaging\build_exe.spec --noconfirm

Output:

  • Windows: dist\EasyPostDesktop\EasyPostDesktop.exe (a folder, not a single file — see below for why). Copy the whole EasyPostDesktop folder anywhere and run the exe inside it without the dev environment.
  • macOS: dist/EasyPostDesktop.app — a proper app bundle with the icon set, ready to drag into /Applications.

The spec declares two Analysis/EXE pairs merged by one COLLECT, so the MCP server ships as a second console executable (easypost-mcp) beside the GUI, sharing its bundled dependencies. Building from source produces both regardless of variant; it is the flag files (above) that decide whether the running app offers the feature.

To reproduce a shipped build locally, create the flags before packaging:

type nul > app\resources\license_required.flag
type nul > app\resources\mcp_supported.flag

(touch on macOS/Linux.) They are gitignored, so a plain source build has both features off — matching the Store variant, not the paid one.

GitHub Actions builds both platforms automatically on every push — see the Actions tab for downloadable artifacts.

Microsoft Store package (MSIX)

The app is also reserved on Microsoft Partner Center as Easy-Post Desktop (Store ID 9NDSDL5LV5B5) for eventual Microsoft Store submission. Build the .msix from an existing dist\EasyPostDesktop\ build:

.venv\Scripts\python.exe packaging\build_msix.py

This produces dist\EasyPostDesktop.msix, using the identity Partner Center assigned (packaging\msix\AppxManifest.xml) and the app's existing icon resized to the required tile sizes — no separate maintenance needed when the icon changes, since assets are generated at build time.

Two rules the packaging enforces, both learned from a failed certification. Submission 1.0.3.0 was rejected under 10.3.4 — "the product failed to install through the Store", because the manifest declared 47 <Resource Language> entries while the package shipped no resources.pri at all. Add-AppxPackage tolerates that, which is exactly why local sideload testing passed and the defect survived all the way to certification; Store deployment is stricter and refuses it.

  • build_msix.py runs makepri to write a real resources.pri into the package, and verify_store_variant() fails the build if it is ever missing again.
  • The manifest declares one language, en-US — what the package actually provides. The app localises at runtime from its own bundled JSON, so it has no MRT language resources to back further declarations. Verified rather than assumed: makepri run against the 47-language manifest produced an index containing zero languages, because there were no language-qualified resources to bind them to.

The 47 localised Store listings live in Partner Center and are entirely independent of this element — reducing the package to en-US does not remove them.

GitHub Actions builds and signs this automatically on every Windows run (alongside the plain .exe) with a throwaway self-signed certificate — see the next paragraph for why that's sufficient. To test-install the locally built package instead, from an elevated PowerShell window (trusting a certificate into the Local Machine store requires admin rights):

.\packaging\sign_msix_local.ps1
Add-AppxPackage -Path dist\EasyPostDesktop.msix

MSIX packages must carry some signature to be structurally valid, but for Microsoft Store submissions specifically, Microsoft explicitly documents that a self-signed certificate is fine — the Store strips it and re-signs with its own certificate during publishing. That means no purchased code-signing certificate and no stored secrets are needed for this path, unlike the plain .exe's SmartScreen problem below. Uninstall a local test install with Get-AppxPackage SFields.Easy-PostDesktop | Remove-AppxPackage.

macOS signing and notarization

macOS Gatekeeper refuses an unsigned or un-notarized app downloaded from the internet, so the direct-download .dmg is signed with a Developer ID Application certificate, notarized by Apple, and stapled. This runs in CI and is gated on MACOS_CERTIFICATE_P12_BASE64 being present, so forks without the secret still build normally.

Required repository secrets:

Secret What it is
MACOS_CERTIFICATE_P12_BASE64 Developer ID cert + key, PKCS#12, base64
MACOS_CERTIFICATE_PASSWORD password for that .p12
MACOS_SIGN_IDENTITY e.g. Developer ID Application: … (TEAMID)
APPLE_ID, APPLE_APP_PASSWORD, APPLE_TEAM_ID notarytool credentials

Two traps worth recording, both of which cost real time here:

  • Export the .p12 with -legacy -macalg sha1. OpenSSL 3.x writes PKCS#12 using AES/PBKDF2 with a SHA-256 MAC, which macOS security import cannot read — it fails with MAC verification failed (wrong password?) even when the password is perfectly correct.
    openssl pkcs12 -export -legacy -macalg sha1 \
      -inkey key.pem -in cert.pem -out DeveloperID.p12
    
  • ditto, not cp, for the .app bundle. cp -R breaks the symlinked Qt frameworks and drops the executable bit, and arm64 macOS refuses a bundle whose signature no longer matches.

Notarization is submitted and waited on separately rather than with submit --wait, so the submission id is always captured even if polling dies. The wait is bounded (--timeout 30m, plus step and job timeouts). To query a submission afterwards without rebuilding, run the Notarization status workflow from the Actions tab with the submission id.

Windows SmartScreen warning

Running a freshly-built EasyPostDesktop.exe on Windows will likely show a blue "Windows protected your PC" SmartScreen prompt. This is expected for any new, low-download-volume executable from an unrecognized publisher — it isn't specific to this app, and it isn't a sign the build is unsafe.

What actually fixes it: a code-signing certificate applied to every release, which lets Windows attribute the build to a verified publisher and accrue reputation over time. This project signs through Azure Artifact Signing (formerly Trusted Signing), with a Microsoft-managed Public Trust certificate issued to CN=Spencer Fields, O=Spencer Fields, L=Poole, S=Dorset, C=GB. Identity validation completed on 2026-08-22.

Signing happens in CI, on the Windows leg of build.yml, and it does not need a stored credential: the runner exchanges a GitHub OIDC token for an Azure one through a federated credential bound to refs/heads/main. There is no private key to protect, because there is no exportable key — Azure holds it and mints a short-lived certificate per request. CI-AZURE-SIGNING-SETUP.md records the account, endpoint, profile and identity plumbing.

A predecessor route is worth not re-deriving. Certum's Open Source Code Signing application was rejected — the site presents a commercial product, and that certificate is for individuals — and the Standard alternative went unanswered, so the route was abandoned on 2026-08-05. It would have forced local signing anyway: since June 2023 the CA/Browser Forum baseline requires standard/OV private keys to live on FIPS-certified hardware, which a GitHub-hosted runner cannot reach. Azure sidesteps that entirely. Do not restart the Certum process, and note that packaging\sign_windows_local.ps1 was removed with it.

Published downloads are signed from v1.2.7 onward. Every release up to and including v1.2.6 carries unsigned Windows binaries, because they were cut before signing was switched on — re-downloading an older tag will still raise the SmartScreen prompt. Signing also changes the archive, so the SHA-256 on the GitHub release asset and in site\download.html has to be taken from the signed build, not the build that preceded it.

A caveat worth setting expectations on: an OV certificate does not clear SmartScreen instantly. Reputation accrues with download volume, so the "unrecognized publisher" warning can persist on a brand-new certificate before Microsoft's reputation service trusts it, and Smart App Control may block a very fresh one outright on locked-down machines. The Microsoft Store build, which Microsoft signs itself, never shows the warning.

What this repo does to reduce false positives regardless:

  • The build uses PyInstaller's --onedir mode rather than --onefile. Onefile builds self-extract into a temp folder on every launch, which is a strong heuristic signal antivirus engines and SmartScreen associate with packers/droppers; onedir avoids that.
  • Every CI build includes a SHA256SUMS.txt alongside the executable so you can verify the download wasn't corrupted or tampered with in transit (Get-FileHash -Algorithm SHA256 EasyPostDesktop.exe on Windows, shasum -a 256 on macOS/Linux, and compare against the matching line in SHA256SUMS.txt).

If you hit the prompt: click "More info" → "Run anyway". That's safe to do for a build you compiled yourself or downloaded from this repo's own GitHub Actions runs.

Mac App Store package (MAS)

A Mac App Store edition is built from the same codebase, marked by app/resources/mas_build.flag. It is the macOS twin of the Microsoft Store variant: free to install and fully usable in EasyPost test mode, with production (real labels) unlocked by a one-time StoreKit In-App Purchase (production_unlock, ~$29.99) rather than a pasted licence key — see app/core/mac_store_entitlement.py, which mirrors the public contract of store_entitlement.py over StoreKit.

Apple's App Sandbox shapes a few build-specific choices, all gated on MAS_BUILD and inert on every other build: the cloudflared push tunnel and the in-app donation/updater are disabled, and AI-agent access runs over the outbound relay (server/mcp-relay-worker/) rather than a local subprocess — so there is no inbound port and no downloaded code, satisfying App Review. Packaging lives in packaging/mas/:

  • EasyPostDesktop.entitlements — the minimal sandbox set (app-sandbox, network.client, user-selected + downloads file access, print).
  • Info.plist.additions, build_mas.sh (build → sign inside-out → productbuild a .pkg), and verify_mas_variant.sh (the mutual-exclusion guard).

Building, signing and submitting a MAS package require macOS + Xcode + an Apple Developer account — it cannot be produced on Windows. The remaining steps after upload are owner-only App Store Connect actions; see MACOS-APP-STORE-PLAN.md.

Repository layout

app/
  config.py              paths, constants, variant-flag detection
                         (LICENSE_REQUIRED, MCP_SUPPORTED)
  mcp_server.py          the MCP server — packaged as its own `easypost-mcp`
                         executable; read tools run, spending tools file
                         requests
  core/                  client, credentials, SQLite, settings, licence,
                         label_options, webhook manager, HTTP receiver, tunnel,
                         mcp_approvals (the gate), mcp_clients (detect/write
                         other apps' MCP configs)
  services/              one module per EasyPost resource (shipments, batches,
                         addresses, tracking, insurance, pickups, claims,
                         packages, hts_lookup) — thin SDK wrapper + local sync;
                         plus mcp_verify (re-fetches approval summaries) and
                         mcp_runner (re-checks everything at execution time)
  ui/                    theme.py (Fusion + stylesheet), main_window.py (shell
                         and grouped nav), views/, widgets/
  resources/locales/     50 language catalogues; en.json is the source of truth
packaging/               PyInstaller spec, MSIX manifest/builder, signing
                         scripts, macOS entitlements,
                         verify_variant_flags.sh (CI backstop)
server/
  paddle-license-webhook-worker/   Cloudflare Worker: Paddle -> licence email
  paddle-license-webhook/          container/FastAPI equivalent, if self-hosting
site/                    easy-post.spencerfields.com — product site, policies,
                         Paddle checkout and the PHP contact form
tools/issue_license.py   mint a licence key by hand
tests/                   pytest suite, no network access required

Conventions worth knowing before contributing

  • en.json is the source of truth for strings. tests/test_i18n.py asserts every one of the other 49 catalogues has an identical key set, so adding a key means adding it everywhere. That test is the safety net for machine-generated translations at this scale.
  • No network in tests. Every external call is mocked. A test that reaches a live API will pass on your machine and fail in CI the day that service is slow — which is exactly what happened once here.
  • Services own persistence, views own presentation. A view should not build EasyPost request bodies, and a service should not import Qt.
  • Carrier names are text, never logo artwork. See app/ui/widgets/chips.py for why, and what would have to change to ship real logos.

About

Native Windows/macOS desktop client for the EasyPost shipping API — rate shopping, labels, tracking, customs, batch CSV, and an MCP server for AI agents.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages