feat: notify owners when repositories expire

This commit is contained in:
Thomas Durieux committed 2026-11-14 12:41:02 +00:00
1 parent 511dcd7067
commit 104af7d261
5 files changed
+142 -2

No files matched your search

+9 -2
View File
@@ -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
+2
View File
@@ -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();
@@ -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 },
+46
View File
@@ -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<void> {
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");
}
}
+84
View File
@@ -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);
});
});