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.
- First-time setup — running from source
- Features
- Distribution — the two channels and how they differ
- Licensing — how the offline licence gate works
- AI agents — the MCP server and its safety model
- Tracking updates
- Running tests
- Building a standalone app
- Repository layout
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.
- 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
.xlsxwith 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.
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_BUILD → app/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.
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.
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: 0and the real reason inerrors. The HTTP code alone proves nothing about whether the write landed. - The host serves
text/htmlwith no charset, sorequestsdecodes 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 defaultpython-requestsuser agent, so the read-back must present a browser one. save_file_contentshifts 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.
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.pyholds the public key and verifies. The private key never ships.tools/issue_license.pymints 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.
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.
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 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.pybuilds 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.pyre-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.
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:
- Starts a local HTTP server bound to
127.0.0.1only (never exposed directly on your LAN). - Opens a Cloudflare Quick Tunnel
(
cloudflared tunnel --url ...) — zero signup, an anonymoushttps://*.trycloudflare.comURL 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. - 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:
- Windows:
winget install --id Cloudflare.cloudflared - macOS:
brew install cloudflared - Linux: see Cloudflare's install docs for your distro, or grab a release binary from github.com/cloudflare/cloudflared/releases
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.
.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
.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 wholeEasyPostDesktopfolder 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.
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.pyrunsmakeprito write a realresources.priinto the package, andverify_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:makeprirun 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 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
.p12with-legacy -macalg sha1. OpenSSL 3.x writes PKCS#12 using AES/PBKDF2 with a SHA-256 MAC, which macOSsecurity importcannot read — it fails withMAC 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, notcp, for the.appbundle.cp -Rbreaks 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.
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
--onedirmode 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.txtalongside the executable so you can verify the download wasn't corrupted or tampered with in transit (Get-FileHash -Algorithm SHA256 EasyPostDesktop.exeon Windows,shasum -a 256on macOS/Linux, and compare against the matching line inSHA256SUMS.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.
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 →productbuilda.pkg), andverify_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.
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
en.jsonis the source of truth for strings.tests/test_i18n.pyasserts 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.pyfor why, and what would have to change to ship real logos.