- Act as a senior implementation partner
- Default to implementation-first execution
- Optimize for production correctness and smallest viable fix
- Avoid speculative rewrites and preserve existing architecture unless explicitly requested
- This file is canonical for this repository; read it first.
- Then read this repo's root
SKILL.md. - When this repo is checked out inside a multi-plugin Medusa workspace, that workspace's shared skills and policy apply on top of this file. Standalone checkouts need nothing beyond this repository.
- This repository is the standalone source for
@uhlhosting/medusa-notification-postal - Treat this repo as the canonical source for the postal plugin package
- Keep changes scoped to the postal plugin unless the user explicitly asks for broader repo or ecosystem work
- Do not import instructions, invariants, or paths from other Medusa repositories
- Keep mutation logic in workflows, not routes
- Keep route handlers thin and typed
- Prefer
AuthenticatedMedusaRequestfor protected admin endpoints and enforce auth insrc/api/middlewares.ts - Keep workflow composition in
src/workflows/*.tsand import workflows statically from routes and handlers - Use Medusa SDK clients where applicable instead of raw
fetch - Preserve the compiled
.medusa/serverbundle as the package publish surface - Do not add npm tokens, automation tokens, or
.npmrcauth entries - Use
glabfor GitLab CI and variable management, andghfor GitHub mirror checks when needed
- Provider auth mode is
smtp-api provider_datamust carry email content and workflow metadata such assubject,html,text,workflow_event, andworkflow_run_id- The admin settings route under
/admin/plugin-settings/postalis a configuration visibility surface and must not expose secrets - Postal admin routes must require authenticated Medusa admin users through route-local middleware
- Postal debug or test sends must use the plugin workflow path so trace metadata is preserved
- Secrets (
POSTAL_API_KEY,POSTAL_WEBHOOK_TOKEN) are sourced from provider options/environment at boot only — never persisted by the plugin and read-only in the admin UI. Non-secret settings (from,base_url,auth_type,test_to) persist in thepostal_settingDML model via the module service; the plugin never writes to.envor mutatesprocess.envon a request path. A boot loader reconciles the persisted row intoprocess.envin memory. That loader must build the module service from its local container cradle, never by resolving the module key: Medusa passes a loader the module's local container and registers the service in the outer container only after every loader has run, socontainer.resolve(POSTAL_PLUGIN_MODULE)there always throws. It must also keep catching its own errors — an uncaught loader error makes Medusa register the module asundefined— and must interpolate the cause into the warning, because the logger drops extra arguments. - Postal HTTP calls must fail fast with a bounded timeout, configurable via
POSTAL_REQUEST_TIMEOUT_MSand clamped to 1–60s - Postal webhook callbacks must use a tokenized store route, and the exact tokenized URL should be surfaced from an admin-only view rather than the settings surface
- Persistence goes through Medusa data primitives: the
postal_settingandpostal_webhook_eventsDML models + module service (no raw SQL, no PG-connection probing), with tables created by migrations (never on request paths) - The admin webhook URL endpoint should return the tokenized path plus an absolute callback URL when the request origin can be resolved
- The provider must reject CR/LF characters in the sender address, subject, and recipients, and require an http/https
base_url - The public webhook route must validate its body and enforce a bounded body-size cap
- Admin message-inspection must delegate to the resolved provider service, not a duplicated Postal HTTP client
- The build must emit TypeScript declarations so every
types/exportstarget advertised inpackage.jsonresolves for consumers - Recording a Postal webhook is idempotent (a replayed message + event type must not duplicate a row) and emits a best-effort
postal.<status>event on the event bus for subscribers - Sends carry an
idempotency_keyderived from the workflow run id + template + recipient when a run id is present, so workflow retries do not duplicate emails - Postal admin UI requests use the Medusa dashboard session through the shared JS SDK client; do not switch the plugin client to standalone JWT storage
- Provider-backed admin routes resolve the configured
postalprovider through Medusa's Notification module provider registry, and health must report unavailable when that provider cannot be resolved - Notification creation uses Medusa's typed
CreateNotificationDTOcontract and selects the registered provider through theemailchannel; do not add the unsupportedprovider_idfield to create payloads
-
Versioning and releases are automated with semantic-release on the default branch, per GitLab's documented example (
docs.gitlab.com/ci/examples/semantic-release/). Commits MUST follow Conventional Commits (fix:→ patch,feat:→ minor,feat!:/BREAKING CHANGE:→ major) — the commit type drives the version bump; never hand-editpackage.jsonversion. -
The
release:semanticjob (stagedeploy, default branch only) runspnpm exec semantic-release, which computes the next version, publishes to the GitLab npm registry (authenticated with the ephemeralCI_JOB_TOKENvia a generated.npmrc), creates a GitLab Release with generated notes, and commits the bumpedpackage.json+v*tag back to the default branch. -
semantic-release requires a masked project CI/CD variable
GITLAB_TOKEN(scopesapi+write_repository) allowed to push to the protected default branch and create protectedv*tags. The npm publish usesCI_JOB_TOKEN, not a static npm token. The GitLab package registry does not support provenance, so the release job setsNPM_CONFIG_PROVENANCE=false. -
The semantic-release plugin chain and options live in
.releaserc.json; keep it aligned with the plugins declared indevDependencies. -
Keep release validation in the repo:
release:verify(pnpm release:check) must pass beforerelease:semanticruns (needs). -
Onboarding/reconciliation: semantic-release derives the last release from git tags, so every published version must have a matching
v<version>tag reachable from the default branch (e.g. thev0.1.17baseline tag added when adopting semantic-release). -
GitHub npm publishing (public npmjs) uses OIDC Trusted Publishing and must verify protected refs and tag/version alignment before publishing. The
npm-publish.ymljob targets thenpm-productionenvironment, which requires a manual reviewer approval; an unapproved deployment expires after 30 days and the run is recorded as failed — no package is published. Approve or reject each publish run deliberately; never leave it pending. -
The GitLab mirror job (
mirror:github) mirrors to GitHub, uses a masked/protected token, and pushes tag refs specifically (refs/tags/...) to avoid conflicts with GitLab's background mirroring. A project-level GitLab push mirror also syncs branches and tags to GitHub on its own schedule; because GitHub does not create workflow runs for a push that carries more than three tags, a mirrored tag must not be relied on to trigger the npmjs publish. After a release tag reaches GitHub, start the publish explicitly withgh workflow run npm-publish.yml --ref v<version>(the workflow'sworkflow_dispatchtrigger applies the same tag/version andmain-ancestry guards as a tag push), then approve thenpm-productiondeployment. -
Security scanning uses the native
Jobs/SAST.gitlab-ci.ymlandJobs/Secret-Detection.gitlab-ci.ymltemplates and runs on merge-request and default-branch pipelines (AST_ENABLE_MR_PIPELINES: "true"). The security findings merge-request widget is Premium/Ultimate-only and this instance is Community Edition (Free), so thesecurity:reportjob surfaces findings in the job log and exposes the raw reports as a downloadable MR artifact (artifacts:expose_as) — tokenless and Free-tier-safe.SECURITY_FAIL_ON_FINDINGS=trueturns it into a gate. -
pnpm's supply-chain verification (
minimumReleaseAge, default 1440 minutes since v11) must stay enabled in every CI job, including the npm publish job — that job holds the OIDC trusted-publishing token and must never trust the lockfile on faith. Do not reach for--trust-lockfileto work around registry throttling. The one known throttling source is@medusajs/*, whose packuments carry thousands of preview/snapshot versions so npmjs rate-limits requests for them (medusajs/medusa#16294); that is handled by the narrowminimumReleaseAgeExcludeentry inpnpm-workspace.yaml, which exempts only that scope while all other dependencies keep the quarantine. GitLab additionally caches pnpm'scache-dir(PNPM_CACHE_DIR, keyed onpnpm-lock.yaml) so the verification result is reused across jobs. Keep the pnpm version in.gitlab-ci.yml(PNPM_VERSION) and innpm-publish.yml(corepack prepare) in sync with thepackageManagerfield inpackage.json. Never add npm auth to raise a rate limit — invariant: no npm tokens in this repo.
pnpm release:checkpasses (includes admin typecheck viatypecheck:admin)npm pack --dry-runincludes the compiled.medusa/serverbundle and the emitted.d.tstype targets- GitHub Actions publish workflow runs without npm tokens
- GitLab CI validates, builds, and mirrors to GitHub on the allowed pipeline sources
- Update this file in the same commit whenever you change plugin architecture, auth behavior, route contracts, publish behavior, or CI/CD mirror behavior
- Use GitLab release assets as generic packages for distributable artifacts
- Keep release assets aligned with the published package version and tag
- Prefer Free-tier-safe release automation: avoid Ultimate-only security or release features unless explicitly requested
Anything printed is permanent. It lands in the agent transcript, the shell history and
any CI log at the same instant, and there is no unprinting it. Treat an accidental
print as a live incident requiring rotation, not a typo. It has happened twice on this
platform: a Proxmox CSI token (2026-07-08, base64 -d to stdout) and GitLab's incoming
email password (2026-07-16, a grep over a config that matched the value along with the
key). The second one happened despite the rule being written down — because it was
written somewhere the agent never read, and only covered writing secrets, not reading a
file that contains one.
Redact in the same command that reads — never afterwards. Output is captured the moment it is emitted. When grepping anything that could hold a credential:
grep -nE "API_KEY|TOKEN|SECRET|PASSWORD" .env | sed -E "s/=.*/= <redacted>/"Rules:
- Never print a
.env, and nevercatone. List keys, not values:grep -oE '^[A-Z_]+' .env - Inject, never read.
infisical run --env <env> -- <cmd>passes values to the child process without them crossing your terminal. Secrets for this platform live in Infisical (cerberus.uhl.cloud), not in the repo — seeSECURITY.mdin the Talos repo for the project layout and rotation procedure. infisical secrets set ... >/dev/null— it echoes the value back in a confirmation table. The redirect is not optional.- Never
kubectl get secret -o yaml, never| base64 -d. Keys only:kubectl -n <ns> get secret <name> -o jsonpath='{.data}' | jq 'keys' - Never print a GitLab CI variable's value (
glab variable get, masked or not), and never echo one inside a job — a masked variable is masked in job logs, not in yours. - Pass secrets on stdin, never argv —
argvshows up inpsand shell history. - Never ask a human to paste a secret into chat. Their paste is transcript too. Hand them a command that reads from stdin instead.
If a value does escape: say so immediately, name exactly what leaked, and rotate it. A quiet fix leaves a live credential in a transcript nobody knows to purge.
- Use Conventional Commits:
type(scope): description. The scope is optional, lowercase, and may contain letters, numbers, dots, slashes, underscores, or hyphens. fixandperfrelease a patch,featreleases a minor, and a!orBREAKING CHANGE:footer releases a major.chore(deps)releases a patch. This is the workspace-wide pattern for dependency updates that must reach the published package.- Other
build,chore,ci,docs,refactor,style, andtestcommits do not release. - Do not use
fixorfeatonly to force a release. The subject must describe the actual change. - Run
pnpm commitlintbefore pushing. It validates the local commit range; the release gate validates the current pipeline commit from GitLab CI.