diff --git a/README.md b/README.md index eab5835..d8ce00a 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,16 @@ the [MongoDB replication guide](docs/mongodb-replication.md). ### Owner email alerts Set `RESEND_API_KEY` and `EMAIL_FROM` on the web server and download workers to -send access alerts through [Resend](https://resend.com/docs/api-reference/emails/send-email). +send access and repository expiration alerts through [Resend](https://resend.com/docs/api-reference/emails/send-email). Use a sender address on a domain verified in Resend. `APP_HOSTNAME` sets the -host used for the connections link in emails. +host used for links in emails. + +After repository expiration completes, the owner receives an email with the +anonymous repository name, expiration date, and a dashboard link. This uses the +owner's alert address, falling back to their saved email. Each repository's +expiration date gets one delivery attempt across workers. Failed or ambiguous +deliveries retain the guard to prevent duplicate emails and are logged without +email addresses. Existing expired repositories are not scanned for notifications. Registration saves a verified GitHub email address, preferring the primary address. OAuth requests `user:email`; GitHub App sign-in uses the read-only email permission diff --git a/src/core/Repository.ts b/src/core/Repository.ts index 63424ed..d906086 100644 --- a/src/core/Repository.ts +++ b/src/core/Repository.ts @@ -4,6 +4,7 @@ import { hasAutomaticUpdates } from "./billing/expiration"; import { FILE_TREE_BATCH_SIZE, FILE_LISTING_LIMIT, deleteRowsInBatches, clearStagedRowsInBatches } from "./file-tree"; import { SourceBase, sourceStorageSize } from "./source/Source"; import { notifyOwnerAccessProblem, withOwnerAccessRecovery } from "./owner-notifications"; +import { notifyRepositoryExpired } from "./repository-expiration-email"; import AnonymizedPathModel from "./model/anonymized-path"; import storage from "./storage"; import { contentGenerationPrefix, contentRetirementMarker } from "./content-generation"; @@ -1045,6 +1046,7 @@ export default class Repository { statusDate: new Date(), isReseted: true, size: { storage: 0, file: 0 } }, $unset: { cleanupToken: "", cleanupUntil: "", pathIndexKey: "", pathIndexBuiltAt: "", sizeComputedAt: "", emptyTreeGeneration: "", retiredTreeGenerations: "", retiredPathIndexKeys: "", retiredContentPrefixes: "", legacyContentCleanupPending: "", stagedFileTrees: "" } }).exec(); if (result.matchedCount) { this.model.status = RepositoryStatus.EXPIRED; this.model.isReseted = true; this.model.size = { storage: 0, file: 0 }; this.model.sizeComputedAt = undefined; this.model.emptyTreeGeneration = undefined; } + if (result.matchedCount) await notifyRepositoryExpired(this.model._id); } finally { clearInterval(heartbeat); await AnonymizedRepositoryModel.updateOne({ _id: this.model._id, cleanupToken: token }, { $unset: { cleanupToken: "", cleanupUntil: "" } }).exec(); diff --git a/src/core/model/anonymizedRepositories/anonymizedRepositories.schema.ts b/src/core/model/anonymizedRepositories/anonymizedRepositories.schema.ts index ced4491..04bea38 100644 --- a/src/core/model/anonymizedRepositories/anonymizedRepositories.schema.ts +++ b/src/core/model/anonymizedRepositories/anonymizedRepositories.schema.ts @@ -5,6 +5,7 @@ import { Schema } from "mongoose"; const AnonymizedRepositorySchema = new Schema({ billingManaged: Boolean, billingNeverExpiresUntil: Date, + expirationEmailClaimedFor: { type: Date, select: false }, accessAlertGeneration: { type: String, select: false }, accessAlertClaimedAt: { type: Date, select: false }, accessAlertAttemptId: { type: String, select: false }, diff --git a/src/core/repository-expiration-email.ts b/src/core/repository-expiration-email.ts new file mode 100644 index 0000000..82812f2 --- /dev/null +++ b/src/core/repository-expiration-email.ts @@ -0,0 +1,46 @@ +import config from "../config"; +import RepositoryModel from "./model/anonymizedRepositories/anonymizedRepositories.model"; +import UserModel from "./model/users/users.model"; +import { notificationEmail } from "./owner-notifications"; +import { createLogger } from "./logger"; +import { RepositoryStatus } from "./types"; + +const logger = createLogger("repository-expiration-email"); + +/** Attempt delivery once per repository expiration date, across all workers. */ +export async function notifyRepositoryExpired(id: unknown): Promise { + if (!config.RESEND_API_KEY || !config.EMAIL_FROM) return; + try { + const repo = await RepositoryModel.findOne({ _id: id, status: RepositoryStatus.EXPIRED }).lean(); + const expirationDate = repo?.options.expirationDate; + if (!repo || repo.options.expirationMode === "never" || !expirationDate || expirationDate > new Date()) return; + const owner = await UserModel.findOne({ _id: repo.owner, status: { $nin: ["removed", "banned"] } }).lean(); + const to = owner && notificationEmail(owner.emails, owner.notificationEmail); + if (!to) return; + const base = /^https?:\/\//.test(config.APP_HOSTNAME) ? config.APP_HOSTNAME : `https://${config.APP_HOSTNAME}`; + const url = new URL("/dashboard", base).href; + const claim = await RepositoryModel.updateOne({ + _id: repo._id, owner: repo.owner, status: RepositoryStatus.EXPIRED, statusDate: repo.statusDate, + "options.expirationMode": repo.options.expirationMode, + "options.expirationDate": expirationDate, + expirationEmailClaimedFor: { $ne: expirationDate }, + }, { $set: { expirationEmailClaimedFor: expirationDate } }).exec(); + if (!claim.matchedCount) return; + // Keep the claim even after a timeout: the provider may have accepted the email. + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${config.RESEND_API_KEY}`, "Content-Type": "application/json", + "Idempotency-Key": `repository-expired/${repo._id}/${expirationDate.getTime()}` }, + signal: AbortSignal.timeout(15000), + body: JSON.stringify({ + from: config.EMAIL_FROM, to: [to], subject: "Your Anonymous GitHub repository has expired", + text: `Your anonymized repository "${repo.repoId}" expired on ${expirationDate.toISOString().slice(0, 10)} (UTC).\n\nIts anonymized content is no longer available.${repo.options.expirationMode === "redirect" ? " Its link now redirects to the original repository." : ""}\n\nOpen your dashboard to manage your repositories:\n${url}\n\nYou are receiving this email because you own this repository on Anonymous GitHub. You can change your alert address on the connections page.\n`, + }), + }); + if (response.ok) logger.info("repository expiration email accepted", { repoId: repo.repoId }); + else logger.warn("repository expiration email delivery failed", { repoId: repo.repoId, httpStatus: response.status }); + } catch { + // Email delivery must not interrupt expiration or expose addresses in logs. + logger.warn("repository expiration email unavailable"); + } +} diff --git a/test/repository-expiration-email.test.js b/test/repository-expiration-email.test.js new file mode 100644 index 0000000..9114ab3 --- /dev/null +++ b/test/repository-expiration-email.test.js @@ -0,0 +1,84 @@ +const { expect } = require("chai"); +require("ts-node/register/transpile-only"); +const config = require("../src/config").default; +const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default; +const Users = require("../src/core/model/users/users.model").default; +const { notifyRepositoryExpired } = require("../src/core/repository-expiration-email"); + +describe("repository expiration email", function () { + const restores = []; + const stub = (object, key, value) => { const old = object[key]; restores.push(() => object[key] = old); object[key] = value; }; + let repo, owner, calls, claims, claimedFor; + beforeEach(() => { + repo = { _id: "repo-object-id", repoId: "anonymous-name", owner: "owner-id", statusDate: new Date(), + options: { expirationMode: "remove", expirationDate: new Date("2025-01-01") } }; + owner = { emails: [{ email: "default@example.com", default: true }], notificationEmail: "preferred@example.com" }; + calls = []; claims = []; claimedFor = undefined; + stub(config, "RESEND_API_KEY", "test-key"); stub(config, "EMAIL_FROM", "alerts@example.com"); + stub(config, "APP_HOSTNAME", "https://anonymous.example.com"); + stub(Repo, "findOne", filter => { + expect(filter.status).to.equal("expired"); + return { lean: async () => repo }; + }); + stub(Users, "findOne", filter => { + expect(filter.status.$nin).to.deep.equal(["removed", "banned"]); + return { lean: async () => owner }; + }); + stub(Repo, "updateOne", (filter, update) => ({ exec: async () => { + claims.push(filter); + const date = update.$set.expirationEmailClaimedFor.getTime(); + if (claimedFor === date) return { matchedCount: 0 }; + claimedFor = date; + return { matchedCount: 1 }; + } })); + stub(global, "fetch", async (url, options) => { calls.push({ url, options }); return { ok: true }; }); + }); + afterEach(() => { while (restores.length) restores.pop()(); }); + + it("sends the preferred address an expiration notice with a dashboard link", async () => { + await notifyRepositoryExpired(repo._id); + const body = JSON.parse(calls[0].options.body); + expect(body.to).to.deep.equal(["preferred@example.com"]); + expect(body.text).to.include('"anonymous-name" expired on 2025-01-01'); + expect(body.text).to.include("https://anonymous.example.com/dashboard"); + expect(body.text).not.to.include("redirects"); + expect(claims[0]).to.include({ owner: repo.owner, status: "expired", statusDate: repo.statusDate }); + expect(claims[0]["options.expirationDate"]).to.equal(repo.options.expirationDate); + }); + it("explains redirect expiration", async () => { + repo.options.expirationMode = "redirect"; + await notifyRepositoryExpired(repo._id); + expect(JSON.parse(calls[0].options.body).text).to.include("redirects to the original repository"); + }); + it("claims concurrent notifications once, and allows a later expiration date", async () => { + await Promise.all([notifyRepositoryExpired(repo._id), notifyRepositoryExpired(repo._id)]); + expect(calls).to.have.length(1); + repo.options.expirationDate = new Date("2025-02-01"); + await notifyRepositoryExpired(repo._id); + expect(calls).to.have.length(2); + }); + for (const scenario of ["disabled", "missing-repo", "never", "future", "missing-owner", "missing-email"]) { + it(`skips ${scenario} without claiming delivery`, async () => { + if (scenario === "disabled") config.RESEND_API_KEY = ""; + if (scenario === "missing-repo") repo = null; + if (scenario === "never") repo.options.expirationMode = "never"; + if (scenario === "future") repo.options.expirationDate = new Date(Date.now() + 86400000); + if (scenario === "missing-owner") owner = null; + if (scenario === "missing-email") owner = {}; + await notifyRepositoryExpired("repo-object-id"); + expect(calls).to.have.length(0); expect(claims).to.have.length(0); + }); + } + it("does not send when the repository changed before the claim", async () => { + stub(Repo, "updateOne", () => ({ exec: async () => ({ matchedCount: 0 }) })); + await notifyRepositoryExpired(repo._id); + expect(calls).to.have.length(0); + }); + for (const failure of ["timeout", "rejection"]) it(`retains the guard on provider ${failure} without interrupting expiration`, async () => { + let attempts = 0; + stub(global, "fetch", async () => { attempts++; if (failure === "timeout") throw Error("timeout"); return { ok: false, status: 503 }; }); + await notifyRepositoryExpired(repo._id); + await notifyRepositoryExpired(repo._id); + expect(attempts).to.equal(1); + }); +});