diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index adc46a0..0000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,22 +0,0 @@ -# This workflow will triage pull requests and apply a label based on the -# paths that are modified in the pull request. -# -# To use this workflow, you will need to set up a .github/labeler.yml -# file with configuration. For more information, see: -# https://github.com/actions/labeler - -name: Pull Request Labeler -on: [pull_request_target] - -jobs: - label: - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - - steps: - - uses: actions/labeler@v4 - with: - repo-token: "${{ secrets.GH_TOKEN }}" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5d18076..5d03efb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,6 +2,9 @@ name: Publish Docker on: push: + branches: + - main + - develop tags: - "*" workflow_dispatch: @@ -16,6 +19,7 @@ jobs: name: Build and publish platform containers runs-on: ubuntu-latest permissions: + contents: read packages: write strategy: fail-fast: false @@ -35,6 +39,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -62,8 +67,33 @@ jobs: DOCKER_FILE: ${{ matrix.app.dockerfile }} CONTEXT: ${{ matrix.app.context }} run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" GIT_SHA="$(git rev-parse HEAD)" + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + REF_TYPE="branch" + REF_NAME="${{ github.event.inputs.branch }}" + else + REF_TYPE="${GITHUB_REF_TYPE}" + REF_NAME="${GITHUB_REF_NAME}" + fi + + TAGS="$GIT_SHA" + APP_VERSION="$GIT_SHA" + + if [ "$REF_TYPE" = "tag" ]; then + TAGS="$TAGS $REF_NAME latest" + APP_VERSION="$REF_NAME" + elif [ "$REF_NAME" = "main" ]; then + TAGS="$TAGS latest" + APP_VERSION="latest" + elif [ "$REF_NAME" = "develop" ]; then + TAGS="$TAGS develop" + APP_VERSION="develop" + fi + + IMAGE_TAG_ARGS="" + for TAG in $TAGS; do + IMAGE_TAG_ARGS="$IMAGE_TAG_ARGS -t bytesend/$APP-$BUILD_PLATFORM:$TAG -t ghcr.io/bytesend/$APP-$BUILD_PLATFORM:$TAG" + done docker buildx build \ -f "$DOCKER_FILE" \ @@ -71,12 +101,7 @@ jobs: --progress=plain \ --build-arg APP_VERSION="$APP_VERSION" \ --build-arg GIT_SHA="$GIT_SHA" \ - -t "bytesend/$APP-$BUILD_PLATFORM:latest" \ - -t "bytesend/$APP-$BUILD_PLATFORM:$GIT_SHA" \ - -t "bytesend/$APP-$BUILD_PLATFORM:$APP_VERSION" \ - -t "ghcr.io/bytesend/$APP-$BUILD_PLATFORM:latest" \ - -t "ghcr.io/bytesend/$APP-$BUILD_PLATFORM:$GIT_SHA" \ - -t "ghcr.io/bytesend/$APP-$BUILD_PLATFORM:$APP_VERSION" \ + $IMAGE_TAG_ARGS \ --push \ "$CONTEXT" @@ -84,6 +109,7 @@ jobs: name: Create and publish manifest runs-on: ubuntu-latest permissions: + contents: read packages: write needs: build_and_publish_platform_containers steps: @@ -91,6 +117,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - name: Login to DockerHub uses: docker/login-action@v3 @@ -107,52 +134,62 @@ jobs: - name: Create and push DockerHub manifest run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" GIT_SHA="$(git rev-parse HEAD)" + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + REF_TYPE="branch" + REF_NAME="${{ github.event.inputs.branch }}" + else + REF_TYPE="${GITHUB_REF_TYPE}" + REF_NAME="${GITHUB_REF_NAME}" + fi + + TAGS="$GIT_SHA" + if [ "$REF_TYPE" = "tag" ]; then + TAGS="$TAGS $REF_NAME latest" + elif [ "$REF_NAME" = "main" ]; then + TAGS="$TAGS latest" + elif [ "$REF_NAME" = "develop" ]; then + TAGS="$TAGS develop" + fi for APP_NAME in bytesend smtp-proxy; do - docker manifest create \ - bytesend/$APP_NAME:latest \ - --amend bytesend/$APP_NAME-amd64:latest \ - --amend bytesend/$APP_NAME-arm64:latest - - docker manifest create \ - bytesend/$APP_NAME:$GIT_SHA \ - --amend bytesend/$APP_NAME-amd64:$GIT_SHA \ - --amend bytesend/$APP_NAME-arm64:$GIT_SHA - - docker manifest create \ - bytesend/$APP_NAME:$APP_VERSION \ - --amend bytesend/$APP_NAME-amd64:$APP_VERSION \ - --amend bytesend/$APP_NAME-arm64:$APP_VERSION - - docker manifest push bytesend/$APP_NAME:latest - docker manifest push bytesend/$APP_NAME:$GIT_SHA - docker manifest push bytesend/$APP_NAME:$APP_VERSION + for TAG in $TAGS; do + docker manifest create \ + bytesend/$APP_NAME:$TAG \ + --amend bytesend/$APP_NAME-amd64:$TAG \ + --amend bytesend/$APP_NAME-arm64:$TAG + + docker manifest push bytesend/$APP_NAME:$TAG + done done - name: Create and push GitHub Container Registry manifest run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" GIT_SHA="$(git rev-parse HEAD)" + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + REF_TYPE="branch" + REF_NAME="${{ github.event.inputs.branch }}" + else + REF_TYPE="${GITHUB_REF_TYPE}" + REF_NAME="${GITHUB_REF_NAME}" + fi + + TAGS="$GIT_SHA" + if [ "$REF_TYPE" = "tag" ]; then + TAGS="$TAGS $REF_NAME latest" + elif [ "$REF_NAME" = "main" ]; then + TAGS="$TAGS latest" + elif [ "$REF_NAME" = "develop" ]; then + TAGS="$TAGS develop" + fi for APP_NAME in bytesend smtp-proxy; do - docker manifest create \ - ghcr.io/bytesend/$APP_NAME:latest \ - --amend ghcr.io/bytesend/$APP_NAME-amd64:latest \ - --amend ghcr.io/bytesend/$APP_NAME-arm64:latest - - docker manifest create \ - ghcr.io/bytesend/$APP_NAME:$GIT_SHA \ - --amend ghcr.io/bytesend/$APP_NAME-amd64:$GIT_SHA \ - --amend ghcr.io/bytesend/$APP_NAME-arm64:$GIT_SHA - - docker manifest create \ - ghcr.io/bytesend/$APP_NAME:$APP_VERSION \ - --amend ghcr.io/bytesend/$APP_NAME-amd64:$APP_VERSION \ - --amend ghcr.io/bytesend/$APP_NAME-arm64:$APP_VERSION - - docker manifest push ghcr.io/bytesend/$APP_NAME:latest - docker manifest push ghcr.io/bytesend/$APP_NAME:$GIT_SHA - docker manifest push ghcr.io/bytesend/$APP_NAME:$APP_VERSION + for TAG in $TAGS; do + docker manifest create \ + ghcr.io/bytesend/$APP_NAME:$TAG \ + --amend ghcr.io/bytesend/$APP_NAME-amd64:$TAG \ + --amend ghcr.io/bytesend/$APP_NAME-arm64:$TAG + + docker manifest push ghcr.io/bytesend/$APP_NAME:$TAG + done done diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml new file mode 100644 index 0000000..158bf3e --- /dev/null +++ b/.github/workflows/npm-release.yml @@ -0,0 +1,60 @@ +name: Release JS Packages + +on: + push: + branches: + - main + paths: + - "packages/sdk/**" # Trigger only changes in packages + - ".github/workflows/npm-release.yml" + workflow_dispatch: + +permissions: + id-token: write # Required for OIDC + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: "https://registry.npmjs.org" + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.0.0 + + - name: Update npm for OIDC support + run: npm install -g npm@latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Publish with Trusted Publishing (OIDC) + if: ${{ secrets.NPM_TOKEN == '' }} + working-directory: packages/sdk + run: | + pnpm run build + npm publish --access public --no-git-checks --provenance + + - name: Publish with NPM token + if: ${{ secrets.NPM_TOKEN != '' }} + working-directory: packages/sdk + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + pnpm run build + npm publish --access public --no-git-checks \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 01705c2..a8f6611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Label action token update** — updated token reference in `.github/workflows/label.yml` - **Website test workflow tuning** — adjusted website test workflow behavior - **Docker publish workflow update** — updated `.github/workflows/docker-publish.yml` +- **Website tests pnpm version alignment** — removed hardcoded pnpm version from `.github/workflows/website-test.yml` so CI uses the repository `packageManager` version (`pnpm@9.0.0`) +- **Docker publish tag strategy hardening** — `.github/workflows/docker-publish.yml` now publishes ref-aware tags (`latest`, `develop`, version tag, and commit SHA) with matching multi-arch manifests +- **Manual Docker publish branch support** — wired `workflow_dispatch` branch input into checkout and tag resolution so manual runs build/publish the selected branch +- **Labeler rules refresh** — updated `.github/labeler.yml` to align automated PR labeling with the current repository structure +- **JavaScript SDK release workflow** — added `.github/workflows/npm-release.yml` to build and publish the `bytesend-js` package from `packages/sdk` on pushes to `main` and manual dispatch ### Fixed @@ -66,6 +71,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Marketing Site - **Contact CTA destination** — changed the marketing contact link from email to Discord +#### CI / Tests +- **Domain-service unit test import stability** — `apps/web/src/server/service/domain-service.ts` now initializes DNS resolvers with runtime-safe fallbacks (promises API or callback API), preventing `ERR_INVALID_ARG_TYPE` when DNS methods are partially mocked in tests +- **Usage unit test expectation alignment** — `apps/web/src/lib/usage.unit.test.ts` now derives expected costs from exported usage constants instead of stale hardcoded values +- **Workspace SDK resolution in Vitest** — `apps/web/vitest.config.ts` now aliases `bytesend-js` to `packages/sdk/index.ts` during tests so unit suites do not depend on prebuilt SDK `dist` artifacts +- **Contact-service unit test isolation** — `apps/web/src/server/service/contact-service.unit.test.ts` now mocks `LimitService.checkContactsLimit` to avoid transitive `TeamService` cache dependencies and prevent brittle failures + +#### SMTP Server +- **SMTP Dockerfile context compatibility** — `apps/smtp-server/Dockerfile` no longer expects `pnpm-lock.yaml` in app-only build contexts and now uses an app-local install path that works with the `apps/smtp-server` Docker build context +- **SMTP container entrypoint correction** — fixed runtime command in `apps/smtp-server/Dockerfile` to execute `dist/server.js` from the container working directory +- **SMTP Docker Corepack compatibility** — pinned Docker image pnpm activation in `apps/smtp-server/Dockerfile` to `pnpm@9.0.0` (instead of `latest`) to avoid Corepack bootstrap/runtime failures in CI builds +- **SMTP package manager metadata** — added `packageManager: pnpm@9.0.0` to `apps/smtp-server/package.json` so Corepack does not auto-inject newer pnpm versions during container installs + ### Security - **SES callback SSRF hardening** — `apps/web/src/app/api/ses_callback/route.ts` no longer fetches user-provided `SubscribeURL` directly; it now constructs a trusted AWS SNS confirmation URL from validated `TopicArn`/`Token` components before issuing the request diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index ce9392a..27a2ef6 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -49,7 +49,7 @@ an individual is officially representing the community in public spaces. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders at support@bytesend.cloud. All complaints will +reported to the community leaders at support@nodebyte.co.uk. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/SUPPORT.md b/SUPPORT.md index 5dba923..155f02b 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -9,11 +9,11 @@ Need help with ByteSend? ## Commercial / Cloud Support -- support@bytesend.cloud +- support@nodebyte.co.uk ## Security Reports Do not open public issues for vulnerabilities. - See `SECURITY.md` -- Email: support@bytesend.cloud +- Email: support@nodebyte.co.uk diff --git a/apps/smtp-server/Dockerfile b/apps/smtp-server/Dockerfile index 15c7d9b..12834e0 100644 --- a/apps/smtp-server/Dockerfile +++ b/apps/smtp-server/Dockerfile @@ -3,14 +3,14 @@ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@latest --activate +RUN corepack prepare pnpm@9.0.0 --activate FROM base AS builder RUN apk add --no-cache libc6-compat WORKDIR /app -COPY package.json pnpm-lock.yaml ./ -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile +COPY package.json ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --no-frozen-lockfile # Build the SMTP server COPY src ./src @@ -32,4 +32,4 @@ COPY --from=builder /app/node_modules ./node_modules # 2465: Alternative SMTPS port EXPOSE 25 465 587 2465 2587 -CMD ["node", "apps/smtp-server/dist/server.js"] +CMD ["node", "dist/server.js"] diff --git a/apps/smtp-server/package.json b/apps/smtp-server/package.json index da662c8..520d934 100644 --- a/apps/smtp-server/package.json +++ b/apps/smtp-server/package.json @@ -10,6 +10,7 @@ }, "keywords": [], "author": "", + "packageManager": "pnpm@9.0.0", "license": "ISC", "dependencies": { "@types/mailparser": "^3.4.5", diff --git a/apps/web/prisma/migrations/20260509000100_add_extra_member_slots_to_team/migration.sql b/apps/web/prisma/migrations/20260509000100_add_extra_member_slots_to_team/migration.sql new file mode 100644 index 0000000..4d9cee9 --- /dev/null +++ b/apps/web/prisma/migrations/20260509000100_add_extra_member_slots_to_team/migration.sql @@ -0,0 +1,3 @@ +-- Add missing Team extra member slots column expected by Prisma schema and tests +ALTER TABLE "Team" +ADD COLUMN IF NOT EXISTS "extraMemberSlots" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/web/src/lib/usage.unit.test.ts b/apps/web/src/lib/usage.unit.test.ts index 4aacfd0..09c28c6 100644 --- a/apps/web/src/lib/usage.unit.test.ts +++ b/apps/web/src/lib/usage.unit.test.ts @@ -5,6 +5,7 @@ import { getUsageDate, getUsageTimestamp, getUsageUnits, + UNIT_PRICE, TRANSACTIONAL_UNIT_CONVERSION, } from "~/lib/usage"; @@ -29,7 +30,9 @@ describe("usage helpers", () => { }); it("calculates cost per email type", () => { - expect(getCost(10, EmailUsageType.MARKETING)).toBe(0.01); - expect(getCost(4, EmailUsageType.TRANSACTIONAL)).toBe(0.001); + expect(getCost(10, EmailUsageType.MARKETING)).toBe(10 * UNIT_PRICE); + expect(getCost(4, EmailUsageType.TRANSACTIONAL)).toBe( + Math.floor(4 / TRANSACTIONAL_UNIT_CONVERSION) * UNIT_PRICE, + ); }); }); diff --git a/apps/web/src/server/api/routers/campaign-security.trpc.test.ts b/apps/web/src/server/api/routers/campaign-security.trpc.test.ts index b8682ef..dcf9357 100644 --- a/apps/web/src/server/api/routers/campaign-security.trpc.test.ts +++ b/apps/web/src/server/api/routers/campaign-security.trpc.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockDb, mockValidateDomainFromEmail } = vi.hoisted(() => ({ +const { mockDb, mockValidateDomainFromEmail, mockCheckMarketingAccess } = vi.hoisted(() => ({ mockDb: { teamUser: { findFirst: vi.fn(), @@ -15,6 +15,7 @@ const { mockDb, mockValidateDomainFromEmail } = vi.hoisted(() => ({ }, }, mockValidateDomainFromEmail: vi.fn(), + mockCheckMarketingAccess: vi.fn(), })); vi.mock("~/server/db", () => ({ @@ -31,6 +32,12 @@ vi.mock("~/server/service/domain-service", () => ({ validateDomainFromEmail: mockValidateDomainFromEmail, })); +vi.mock("~/server/service/limit-service", () => ({ + LimitService: { + checkMarketingAccess: mockCheckMarketingAccess, + }, +})); + import { createCallerFactory } from "~/server/api/trpc"; import { campaignRouter } from "~/server/api/routers/campaign"; @@ -59,6 +66,8 @@ describe("campaignRouter.updateCampaign authorization", () => { mockDb.campaign.update.mockReset(); mockDb.campaign.create.mockReset(); mockDb.contactBook.findUnique.mockReset(); + mockCheckMarketingAccess.mockReset(); + mockCheckMarketingAccess.mockResolvedValue(true); mockDb.teamUser.findFirst.mockResolvedValue({ teamId: 10, @@ -115,6 +124,8 @@ describe("campaignRouter.duplicateCampaign", () => { mockDb.teamUser.findFirst.mockReset(); mockDb.campaign.findUnique.mockReset(); mockDb.campaign.create.mockReset(); + mockCheckMarketingAccess.mockReset(); + mockCheckMarketingAccess.mockResolvedValue(true); mockDb.teamUser.findFirst.mockResolvedValue({ teamId: 10, diff --git a/apps/web/src/server/service/contact-service.unit.test.ts b/apps/web/src/server/service/contact-service.unit.test.ts index 2c38144..86b424f 100644 --- a/apps/web/src/server/service/contact-service.unit.test.ts +++ b/apps/web/src/server/service/contact-service.unit.test.ts @@ -5,6 +5,7 @@ const { mockWebhookEmit, mockSendDoubleOptInConfirmationEmail, mockAddBulkContactJobs, + mockCheckContactsLimit, mockLogger, } = vi.hoisted(() => ({ mockDb: { @@ -21,6 +22,7 @@ const { mockWebhookEmit: vi.fn(), mockSendDoubleOptInConfirmationEmail: vi.fn(), mockAddBulkContactJobs: vi.fn(), + mockCheckContactsLimit: vi.fn(), mockLogger: { warn: vi.fn(), error: vi.fn(), @@ -47,6 +49,12 @@ vi.mock("~/server/service/contact-queue-service", () => ({ }, })); +vi.mock("~/server/service/limit-service", () => ({ + LimitService: { + checkContactsLimit: mockCheckContactsLimit, + }, +})); + vi.mock("~/server/logger/log", () => ({ logger: mockLogger, })); @@ -66,6 +74,12 @@ describe("contact-service addOrUpdateContact", () => { mockDb.contact.findUnique.mockReset(); mockDb.contact.update.mockReset(); mockDb.contact.upsert.mockReset(); + mockCheckContactsLimit.mockReset(); + mockCheckContactsLimit.mockResolvedValue({ + isLimitReached: false, + limit: -1, + currentCount: 0, + }); mockWebhookEmit.mockReset(); mockSendDoubleOptInConfirmationEmail.mockReset(); mockLogger.warn.mockReset(); diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index b15bcc1..bd1b614 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -116,8 +116,21 @@ function withDnsRecords( }; } -const dnsResolveTxt = util.promisify(dns.resolveTxt); -const dnsResolveMx = util.promisify(dns.resolveMx); +const dnsResolveTxt = dns.promises?.resolveTxt + ? dns.promises.resolveTxt.bind(dns.promises) + : typeof dns.resolveTxt === "function" + ? util.promisify(dns.resolveTxt.bind(dns)) + : (async () => { + throw new Error("DNS TXT resolver unavailable"); + }) as (hostname: string) => Promise; + +const dnsResolveMx = dns.promises?.resolveMx + ? dns.promises.resolveMx.bind(dns.promises) + : typeof dns.resolveMx === "function" + ? util.promisify(dns.resolveMx.bind(dns)) + : (async () => { + throw new Error("DNS MX resolver unavailable"); + }) as (hostname: string) => Promise>; /** How long DKIM must be stuck in PENDING/NOT_STARTED before we auto-reregister */ const DKIM_STUCK_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index c15cb09..da24881 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,8 +1,18 @@ import { defineConfig } from "vitest/config"; import tsconfigPaths from "vite-tsconfig-paths"; +import { fileURLToPath } from "node:url"; + +const bytesendSdkEntry = fileURLToPath( + new URL("../../packages/sdk/index.ts", import.meta.url), +); export default defineConfig({ plugins: [tsconfigPaths()], + resolve: { + alias: { + "bytesend-js": bytesendSdkEntry, + }, + }, test: { environment: "node", globals: true,