From 998f9c69308ee88fac36cfb77dfb6d07c6fa926a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 17:46:48 +0200 Subject: feat(hub): invitation-link tickets bound to a verified address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit group_invite_links holds sha256(ticket) and the invitee's address blind index; redeeming grants membership to that account only. Owner-only create/list/cancel (a node token may create, never mail), 20 outstanding per group, optional mail written by the hub itself and capped at 10 per sender per day (mail.invite_link_daily_cap). MESHBAY_DESIGN.md §3.4 now carries the whole link design. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/api/invite_links.py | 336 +++++++++++++++++++++ packages/meshbay-hub/src/meshbay_hub/api/users.py | 5 + packages/meshbay-hub/src/meshbay_hub/app.py | 4 + packages/meshbay-hub/src/meshbay_hub/config.py | 7 +- .../b2c3d4e5f6a7_add_group_invite_links.py | 46 +++ packages/meshbay-hub/src/meshbay_hub/db/models.py | 34 +++ .../meshbay-hub/src/meshbay_hub/hub_settings.py | 2 + packages/meshbay-hub/src/meshbay_hub/mail.py | 54 +++- .../src/meshbay_hub/static/admin-page.js | 1 + .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../meshbay-hub/src/meshbay_hub/tasks/cleanup.py | 16 +- 20 files changed, 507 insertions(+), 8 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/api/invite_links.py create mode 100644 packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py new file mode 100644 index 0000000..d44576b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py @@ -0,0 +1,336 @@ +""" +Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3). + +A link carries two secrets with two jobs. The node's code decides who gets the +group key, and it is shown to the hub only when the inviter asks the hub to mail +the link. The ticket here decides who may *reach* the node — membership, +which is all the hub has to give (§7.1) — and it gives it to one account only: +the one whose verified address the inviter named. A ticket that leaks, through a +messaging service that previews links or a forwarded mail, is therefore useless +without that mailbox. + +Stated per the design's convention: that binding holds against third parties and +not against this hub, which verifies the addresses it compares. An active hub +could already be anybody. + +No route here answers without an account, and nothing tells the inviter whether +an address has an account (M1): creating a link looks the same either way. +""" + +import hashlib +import re +import secrets +from datetime import UTC, datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, field_validator +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub import mail +from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope +from meshbay_hub.api.middleware import limiter +from meshbay_hub.auth import hash_email_blind +from meshbay_hub.db.engine import get_db +from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User + +router = APIRouter(prefix="/v1/groups", tags=["invite-links"]) +redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"]) + +# The node holds at most as many unredeemed link codes per group; one more +# ticket here than codes there would be a link that cannot work. +MAX_OUTSTANDING_PER_GROUP = 20 +# A node's invitation lifetime is the operator's setting (7 days by default); +# the ticket follows it, up to this. +MAX_LIFETIME = timedelta(days=30) +# How long a redeemed link stays in the owner's list, saying who used it. +KEEP_REDEEMED = timedelta(days=30) + +_TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$") # secrets.token_urlsafe(16) +_NODE_INVITE_ID = re.compile(r"^[0-9a-f]{32}$") # roster.create_link_invite +_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$") # roster.generate_code +_B64URL_KEY = re.compile(r"^[A-Za-z0-9_-]{43}$") # 32 bytes, unpadded + + +def ticket_hash(ticket: str) -> str: + return hashlib.sha256(ticket.encode()).hexdigest() + + +def invite_url(group_id: str, ticket: str, node_pk: str, code: str) -> str: + """ + The one shape an invitation link has (docs/MESHBAY_DESIGN.md §3.4). + + Everything after `#`, so none of it is sent to this hub in a request — it + reaches the page and the page alone, which parses this same shape. + """ + return (f"{mail.hub_url()}/#/invite?v=1&g={group_id}&t={ticket}" + f"&n={node_pk}&c={code}") + + +def _mask(email: str) -> str: + """`al***@ex***.com` — enough for the owner to recognise, not to reuse.""" + local, _, domain = email.strip().lower().partition("@") + name, _, tld = domain.rpartition(".") + return f"{local[:2]}***@{name[:2]}***.{tld}" + + +def _aware(when: datetime) -> datetime: + # SQLite hands a timezone-aware column back naive. + return when if when.tzinfo else when.replace(tzinfo=UTC) + + +def _valid_email(v: str) -> str: + v = v.strip() + local, sep, domain = v.partition("@") + if (not sep or not local or not domain or "." not in domain.strip(".") + or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)): + raise ValueError("invalid email address") + return v + + +class CreateLinkRequest(BaseModel): + email: str + expires_at: str + node_invite_id: str + # Only when the hub is to mail the link, since only then must it write it: + # the node key in its URL-safe form, and the node's code — which the hub + # therefore reads. Unchecked in the interface, neither is sent. + send_email: bool = False + node_pk: str = "" + code: str = "" + + @field_validator("email") + @classmethod + def email_valid(cls, v: str) -> str: + return _valid_email(v) + + +class TicketRequest(BaseModel): + ticket: str + + +async def _owned_group(db: AsyncSession, group_id: str, user: User) -> Group: + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != user.id: + raise HTTPException(status_code=403, + detail="Only the group owner can manage its invitation links") + return group + + +@router.post("/{group_id}/invite-links", status_code=201) +@limiter.limit("20/hour") +async def create_invite_link( + group_id: str, + body: CreateLinkRequest, + request: Request, + payload: dict = Depends(_decode_token), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Mint the ticket for a link whose node half already exists. + + `get_current_user`, not `require_user_scope`: `meshbay-node member invite + --link` asks with the node's token, as the CLI invitation already does, and + the owner check is the guard — a node can only act for its own operator's + groups. The CLI mails nothing (`USERGUIDE.md` §7), so a node token asking + for mail is refused rather than quietly obeyed. + """ + group = await _owned_group(db, group_id, current_user) + if group.status != "active": + raise HTTPException(status_code=409, detail="This group is not active") + if group.join_policy == "open": + raise HTTPException(status_code=409, + detail="An open group admits anyone; it needs no invitation") + if not _NODE_INVITE_ID.match(body.node_invite_id): + raise HTTPException(status_code=422, detail="Not a node invitation id") + if body.send_email: + if payload.get("scope") == "node": + raise HTTPException(status_code=403, + detail="Invitation mail is sent from the interface only") + if not (_CODE.match(body.code) and _B64URL_KEY.match(body.node_pk)): + raise HTTPException(status_code=422, + detail="Mailing a link needs its code and node key") + + now = datetime.now(UTC) + try: + expires = _aware(datetime.fromisoformat(body.expires_at)) + except ValueError: + raise HTTPException(status_code=422, detail="Not a date") from None + if expires <= now: + raise HTTPException(status_code=422, detail="That invitation has already expired") + expires = min(expires, now + MAX_LIFETIME) + + await _purge(db, group_id, now) + outstanding = (await db.execute( + select(func.count()).select_from(GroupInviteLink).where( + GroupInviteLink.group_id == group_id, + GroupInviteLink.redeemed_by.is_(None), + GroupInviteLink.expires_at > now))).scalar() or 0 + if outstanding >= MAX_OUTSTANDING_PER_GROUP: + raise HTTPException( + status_code=429, + detail=f"This group already has {outstanding} unused invitation links") + + ticket = secrets.token_urlsafe(16) + row = GroupInviteLink( + group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket), + email_hash=hash_email_blind(body.email), email_masked=_mask(body.email), + node_invite_id=body.node_invite_id, expires_at=expires) + db.add(row) + await db.flush() + + email_status = "not_requested" + if body.send_email: + try: + sent = await mail.send_off_loop( + db, mail.send_invite_link, body.email, + invite_url(group_id, ticket, body.node_pk, body.code.upper()), + current_user.username, group.name, + purpose="invite_link", sender=current_user.id) + email_status = "sent" if sent else "refused" + except Exception: + email_status = "send_failed" + + await db.commit() + return {"link_id": row.id, "ticket": ticket, "expires_at": expires.isoformat(), + "email_status": email_status} + + +@router.get("/{group_id}/invite-links") +async def list_invite_links( + group_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """The owner's view: who each link was for, masked, and whether it was used.""" + await _owned_group(db, group_id, current_user) + rows = (await db.execute( + select(GroupInviteLink, User.username) + .outerjoin(User, User.id == GroupInviteLink.redeemed_by) + .where(GroupInviteLink.group_id == group_id) + .order_by(GroupInviteLink.created_at.desc()) + .limit(200))).all() + now = datetime.now(UTC) + return {"links": [{ + "link_id": r.id, + "email": r.email_masked, + "node_invite_id": r.node_invite_id, + "created_at": _aware(r.created_at).isoformat(), + "expires_at": _aware(r.expires_at).isoformat(), + "status": ("redeemed" if r.redeemed_by + else "expired" if _aware(r.expires_at) <= now else "pending"), + "redeemed_by": name, + } for r, name in rows]} + + +@router.delete("/{group_id}/invite-links/{link_id}") +async def delete_invite_link( + group_id: str, + link_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Take the ticket back. The node's code is the client's half to cancel, + first, with a signed `invite_cancel` — this only closes the door here.""" + await _owned_group(db, group_id, current_user) + row = await db.get(GroupInviteLink, link_id) + if not row or row.group_id != group_id: + raise HTTPException(status_code=404, detail="No such invitation link") + if row.redeemed_by: + raise HTTPException(status_code=409, + detail="This link has been used; remove the member instead") + await db.delete(row) + await db.commit() + return {"status": "deleted", "link_id": link_id} + + +async def _resolve(db: AsyncSession, ticket: str, user: User + ) -> tuple[GroupInviteLink, Group]: + """ + The link this ticket names, if this account may use it. + + One uniform refusal for everything that says nothing about the account — + unknown, spent by someone else, expired, a group no longer active — and one + distinct answer, `invite_other_account`, for the case the person can act + on: signed in as somebody other than the address it was sent to. That + answer names no address, and it is given only to someone holding the + ticket, who already knows a link exists. + """ + invalid = HTTPException(status_code=404, detail="invite_not_valid") + if not _TICKET.match(ticket or ""): + raise invalid + row = (await db.execute(select(GroupInviteLink).where( + GroupInviteLink.ticket_hash == ticket_hash(ticket)))).scalar_one_or_none() + if row is None: + raise invalid + group = await db.get(Group, row.group_id) + if group is None or group.status != "active": + raise invalid + if row.redeemed_by and row.redeemed_by != user.id: + raise invalid + if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC): + raise invalid + if not user.email_hash or user.email_hash != row.email_hash: + raise HTTPException(status_code=403, detail="invite_other_account") + return row, group + + +@redeem_router.post("/preview") +@limiter.limit("30/minute") +async def preview_invite_link( + body: TicketRequest, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """What the confirmation screen shows before anyone joins anything.""" + row, group = await _resolve(db, body.ticket, current_user) + inviter = await db.get(User, row.created_by) + member = await db.get(GroupMember, (group.id, current_user.id)) + return {"group_id": group.id, "group_name": group.name, + "inviter": inviter.username if inviter else "", + "expires_at": _aware(row.expires_at).isoformat(), + "already_member": member is not None} + + +@redeem_router.post("/redeem") +@limiter.limit("30/minute") +async def redeem_invite_link( + body: TicketRequest, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Membership for the addressed account, once — and the same answer again for + that account, because a second tab or a reload is the same person. + """ + row, group = await _resolve(db, body.ticket, current_user) + if not row.redeemed_by: + claimed = await db.execute( + update(GroupInviteLink) + .where(GroupInviteLink.id == row.id, GroupInviteLink.redeemed_by.is_(None)) + .values(redeemed_by=current_user.id, redeemed_at=datetime.now(UTC))) + if claimed.rowcount == 0: + raise HTTPException(status_code=404, detail="invite_not_valid") + if not await db.get(GroupMember, (group.id, current_user.id)): + db.add(GroupMember(group_id=group.id, user_id=current_user.id)) + from meshbay_hub.api.notifications import create_notification + await create_notification( + db, row.created_by, "invite_link_redeemed", + f"{current_user.username} joined {group.name} through your invitation link", + link=f"#/group/{group.id}", group_id=group.id) + await db.commit() + return {"group_id": group.id, "group_name": group.name} + + +async def _purge(db: AsyncSession, group_id: str, now: datetime) -> None: + """Forget this group's dead links: unused ones once expired, used ones after + `KEEP_REDEEMED`. Done on the way in, so the table cannot outgrow its use.""" + await db.execute(delete(GroupInviteLink).where( + GroupInviteLink.group_id == group_id, + ((GroupInviteLink.redeemed_by.is_(None) & (GroupInviteLink.expires_at <= now)) + | (GroupInviteLink.redeemed_at < now - KEEP_REDEEMED)))) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 9b9a189..a994acb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -35,6 +35,7 @@ from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( EmailVerification, Group, + GroupInviteLink, GroupMember, IPLog, Node, @@ -1396,6 +1397,10 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) await db.execute(delete(SwarmSource).where(SwarmSource.node_id == user.id)) await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id)) + # Links this account issued for a group it no longer owns; the ones for its + # own groups went with them above. A used link keeps pointing at the + # tombstone of whoever used it, which is the record of the join. + await db.execute(delete(GroupInviteLink).where(GroupInviteLink.created_by == user.id)) username = user.username # Before the name is released: the connection log is kept for its legal diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 209dc32..b1d9626 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -25,6 +25,8 @@ from meshbay_hub.api.groups import swarm_router from meshbay_hub.api.health import router as health_router from meshbay_hub.api.hub import router as hub_router from meshbay_hub.api.hub import set_config as hub_set_config +from meshbay_hub.api.invite_links import redeem_router as invite_links_redeem_router +from meshbay_hub.api.invite_links import router as invite_links_router from meshbay_hub.api.middleware import limiter from meshbay_hub.api.moderation import router as moderation_router from meshbay_hub.api.nodes import router as nodes_router @@ -195,6 +197,8 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(users_router) app.include_router(nodes_router) app.include_router(groups_router) + app.include_router(invite_links_router) + app.include_router(invite_links_redeem_router) app.include_router(swarm_router) app.include_router(revocation_router) app.include_router(moderation_router) diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py index f2212de..e618510 100644 --- a/packages/meshbay-hub/src/meshbay_hub/config.py +++ b/packages/meshbay-hub/src/meshbay_hub/config.py @@ -130,6 +130,11 @@ class MailConfig: # recipient, and without the exemption a typo locks the account out for the # whole window. email_change_cooldown: int = 172800 # 48 hours + # Invitation-link mails one account may have the hub send per day. The only + # per-account bound here, because a link reaches addresses with no account, + # and the per-recipient cap alone would let one account mail ten strangers + # each, without end. + invite_link_daily_cap: int = 10 @dataclass @@ -174,7 +179,7 @@ def load_config(path: Path | None = None) -> HubConfig: "destination_cooldown_seconds", "destination_daily_cap", "hourly_budget", "hourly_reserved_for_recovery", "verification_resend_cooldown", "reset_cooldown", - "email_change_cooldown", + "email_change_cooldown", "invite_link_daily_cap", ): setattr(cfg.mail, name, ml.get(name, getattr(cfg.mail, name))) if cap := raw.get("captcha", {}): diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py new file mode 100644 index 0000000..75b4d49 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py @@ -0,0 +1,46 @@ +"""add group_invite_links + +The hub's half of an invitation link: a ticket, stored hashed, that grants +membership of one group to the one account whose verified address matches. + +Revision ID: b2c3d4e5f6a7 +Revises: a9b8c7d6e5f4 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "b2c3d4e5f6a7" +down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "group_invite_links", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("group_id", sa.String(36), sa.ForeignKey("groups.id"), nullable=False), + sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("ticket_hash", sa.String(64), nullable=False), + sa.Column("email_hash", sa.String(64), nullable=False), + sa.Column("email_masked", sa.String(128), nullable=False), + sa.Column("node_invite_id", sa.String(32), nullable=False, server_default=""), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("redeemed_by", sa.String(36), sa.ForeignKey("users.id")), + sa.Column("redeemed_at", sa.DateTime(timezone=True)), + ) + op.create_index("uq_invite_links_ticket", "group_invite_links", ["ticket_hash"], + unique=True) + op.create_index("ix_invite_links_group", "group_invite_links", ["group_id"]) + + +def downgrade() -> None: + # Outstanding links stop working; the node halves stay until they expire. + op.drop_index("ix_invite_links_group", table_name="group_invite_links") + op.drop_index("uq_invite_links_ticket", table_name="group_invite_links") + op.drop_table("group_invite_links") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index ac1828f..51a3d47 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -145,6 +145,40 @@ class GroupMember(Base): user: Mapped["User"] = relationship(back_populates="group_memberships") +class GroupInviteLink(Base): + """ + The hub's half of an invitation link (docs/MESHBAY_DESIGN.md §7.3). + + A link carries two secrets. The node's code decides whether someone gets the + group key, and the hub never sees it. This row decides whether someone may + *reach* the node at all — membership, which is all the hub has to give — and + only for the account whose verified address matches `email_hash`. The ticket + is stored as `sha256(ticket)`, so a copy of this table opens nothing. + + No address in the clear: `email_hash` is the same blind index `users` has, + and `email_masked` is what the owner's list shows (`al***@ex***.com`). + """ + __tablename__ = "group_invite_links" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), nullable=False) + created_by: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) + ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False) + email_hash: Mapped[str] = mapped_column(String(64), nullable=False) + email_masked: Mapped[str] = mapped_column(String(128), nullable=False) + # The node's handle for its half, so cancelling can take back both. + node_invite_id: Mapped[str] = mapped_column(String(32), nullable=False, default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + redeemed_by: Mapped[str | None] = mapped_column(ForeignKey("users.id")) + redeemed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = ( + Index("uq_invite_links_ticket", "ticket_hash", unique=True), + Index("ix_invite_links_group", "group_id"), + ) + + # ── Refresh tokens ──────────────────────────────────────────────────────────── class RefreshToken(Base): diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py index c397f58..e01bfd2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py +++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py @@ -35,6 +35,7 @@ MAIL_KEYS = ( "verification_resend_cooldown", "reset_cooldown", "email_change_cooldown", + "invite_link_daily_cap", ) # What a value may be. A cooldown of zero disables it, which is a legitimate @@ -49,6 +50,7 @@ MAIL_BOUNDS: dict[str, tuple[int, int]] = { "verification_resend_cooldown": (0, 86_400), "reset_cooldown": (0, 86_400), "email_change_cooldown": (0, 2_592_000), # 30 days + "invite_link_daily_cap": (1, 1_000), } _mail_defaults: dict[str, int] = {} diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index 1eb7c51..d2659e0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -43,13 +43,14 @@ class MailRefused(Exception): # registration confirm an address at sign-up # password_reset a code to the address already on file for that account # invite tell a registered member they were invited to a group +# invite_link send an invitation link to an address, account or not # email_change confirm a new address before it replaces the old one # -# Only `registration` and `email_change` can reach an address this hub has no -# prior relationship with. Both are necessary; both are why the bound that +# Only `registration`, `email_change` and `invite_link` can reach an address this +# hub has no prior relationship with. Both are necessary; both are why the bound that # matters is keyed on the recipient rather than on who asked. ALLOWED_PURPOSES = frozenset({ - "registration", "password_reset", "invite", "email_change"}) + "registration", "password_reset", "invite", "email_change", "invite_link"}) # The two a person is actively waiting on. They may spend the whole hourly # budget; the other two may not spend the reserved share of it, so a flood of @@ -166,7 +167,7 @@ async def _announce_exhaustion(scope: str) -> None: log.warning("Could not announce the mail budget: %s", e) -async def reserve(db, purpose: str, address: str) -> None: +async def reserve(db, purpose: str, address: str, *, sender: str = "") -> None: """Charge one send, or raise MailRefused. The caller owns the commit.""" from meshbay_hub import hub_settings @@ -175,6 +176,18 @@ async def reserve(db, purpose: str, address: str) -> None: limits = await hub_settings.mail_limits(db) + # An invitation link goes to an address that may belong to nobody here, at + # the request of anyone who owns a group — which is anyone, since + # registration is open. So the account asking is counted too, per day + # (docs/MESHBAY_DESIGN.md §3.4, AV29). Charged first: of the three, it is the one + # bound on the person causing the mail, and a refusal further down leaves + # it spent, which errs towards less mail. No sender, no link mail. + if purpose == "invite_link": + if not sender: + raise MailRefused("an invitation link mail names the account sending it") + await _take(db, f"invite_link:{sender}", timedelta(days=1), + limits["invite_link_daily_cap"], None) + # The instance ceiling first, and the recovery share is subtracted for the # purposes that are not one. Registration is open, so a limit counted per # account or per IP is one an attacker buys more of; this one is not. @@ -283,7 +296,7 @@ def _send(msg: EmailMessage, *, purpose: str) -> bool: return False -async def send_off_loop(db, fn, *args, purpose: str, **kwargs) -> bool: +async def send_off_loop(db, fn, *args, purpose: str, sender: str = "", **kwargs) -> bool: """Run one of the `send_*` functions below in a worker thread. `smtplib` is synchronous and this one waits up to ten seconds. Called @@ -304,7 +317,7 @@ async def send_off_loop(db, fn, *args, purpose: str, **kwargs) -> bool: nobody received. """ try: - await reserve(db, purpose, args[0]) + await reserve(db, purpose, args[0], sender=sender) except MailRefused as refusal: # Never the address: this line goes to the journal. log.warning("Mail refused (%s): %s", purpose, refusal) @@ -421,6 +434,35 @@ def send_invite_notification( log.info("Invite notification sent to %s", _mask_email(to)) +def send_invite_link(to: str, link: str, inviter: str, group_name: str) -> None: + """An invitation link, to an address that may have no account yet. + + Nothing in it comes from the request but the link, and the link is checked + by the caller to be this hub's own invitation URL before it gets here. + """ + msg = EmailMessage() + msg["From"] = f"noreply@{_hub_domain}" + msg["To"] = to + msg["Subject"] = f"MeshBay — {inviter} invited you to {group_name}" + msg.set_content( + f"{inviter} invited you to the group \"{group_name}\" on MeshBay.\n" + "\n" + "Open this link to create your account, or to sign in, and join the group:\n" + "\n" + f"{link}\n" + "\n" + "It works once, and only for an account registered with this address.\n" + "If you did not expect it, you can ignore this message.\n" + ) + _send(msg, purpose="invite_link") + log.info("Invitation link sent to %s", _mask_email(to)) + + +def hub_url() -> str: + """This hub's own address, as every mail it sends names it.""" + return _hub_url + + def _mask_email(email: str) -> str: local, _, domain = email.partition("@") if len(local) <= 2: diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js index b3d71dd..8cbfb5a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js @@ -195,6 +195,7 @@ const MAIL_FIELDS = [ 'hourly_reserved_for_recovery', 'destination_daily_cap', 'destination_cooldown_seconds', + 'invite_link_daily_cap', 'verification_resend_cooldown', 'reset_cooldown', 'email_change_cooldown', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 81a6689..17a184e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -553,6 +553,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "Davon reserviert für Rücksetzungen und Einladungen", 'admin.mail_destination_daily_cap': "Nachrichten pro Tag an einen Empfänger", 'admin.mail_destination_cooldown_seconds': "Sekunden zwischen Nachrichten an einen Empfänger", + 'admin.mail_invite_link_daily_cap': 'Einladungslink-E-Mails pro Tag und Konto', 'admin.mail_verification_resend_cooldown': "Sekunden zwischen zwei Registrierungscodes", 'admin.mail_reset_cooldown': "Sekunden zwischen zwei Rücksetzungscodes", 'admin.mail_email_change_cooldown': "Sekunden, bevor ein Konto eine andere Adresse vorschlagen darf", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 4dee9f3..a58fdd6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -541,6 +541,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "Of those, reserved for resets and invitations", 'admin.mail_destination_daily_cap': "Messages per day to one recipient", 'admin.mail_destination_cooldown_seconds': "Seconds between messages to one recipient", + 'admin.mail_invite_link_daily_cap': 'Invitation-link mails per day from one account', 'admin.mail_verification_resend_cooldown': "Seconds between two sign-up codes", 'admin.mail_reset_cooldown': "Seconds between two passphrase-reset codes", 'admin.mail_email_change_cooldown': "Seconds before an account may propose another address", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 567838d..09592ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -548,6 +548,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "De ellos, reservados para restablecimientos e invitaciones", 'admin.mail_destination_daily_cap': "Mensajes por día a un mismo destinatario", 'admin.mail_destination_cooldown_seconds': "Segundos entre mensajes al mismo destinatario", + 'admin.mail_invite_link_daily_cap': 'Correos de enlace de invitación por día y por cuenta', 'admin.mail_verification_resend_cooldown': "Segundos entre dos códigos de registro", 'admin.mail_reset_cooldown': "Segundos entre dos códigos de restablecimiento", 'admin.mail_email_change_cooldown': "Segundos antes de que una cuenta pueda proponer otra dirección", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 0cc176f..b77bf2b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -551,6 +551,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "Dont réservés aux réinitialisations et invitations", 'admin.mail_destination_daily_cap': "Messages par jour vers un même destinataire", 'admin.mail_destination_cooldown_seconds': "Secondes entre deux messages au même destinataire", + 'admin.mail_invite_link_daily_cap': 'E-mails de lien d’invitation par jour et par compte', 'admin.mail_verification_resend_cooldown': "Secondes entre deux codes d'inscription", 'admin.mail_reset_cooldown': "Secondes entre deux codes de réinitialisation", 'admin.mail_email_change_cooldown': "Secondes avant qu'un compte puisse proposer une autre adresse", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 03a1762..2deb4fc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -551,6 +551,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "Di cui riservati a reimpostazioni e inviti", 'admin.mail_destination_daily_cap': "Messaggi al giorno verso uno stesso destinatario", 'admin.mail_destination_cooldown_seconds': "Secondi tra due messaggi allo stesso destinatario", + 'admin.mail_invite_link_daily_cap': 'E-mail con link d’invito al giorno per account', 'admin.mail_verification_resend_cooldown': "Secondi tra due codici di registrazione", 'admin.mail_reset_cooldown': "Secondi tra due codici di reimpostazione", 'admin.mail_email_change_cooldown': "Secondi prima che un account possa proporre un altro indirizzo", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index f5447bf..0b46994 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -544,6 +544,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "うち再設定と招待のために確保する数", 'admin.mail_destination_daily_cap': "同一受信者への 1 日あたりの通数", 'admin.mail_destination_cooldown_seconds': "同一受信者への送信間隔(秒)", + 'admin.mail_invite_link_daily_cap': '1アカウントあたり1日の招待リンクメール数', 'admin.mail_verification_resend_cooldown': "登録コード再送の間隔(秒)", 'admin.mail_reset_cooldown': "再設定コード再送の間隔(秒)", 'admin.mail_email_change_cooldown': "別のアドレスを申請できるようになるまでの秒数", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index b051a87..310bb4d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -552,6 +552,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "Daarvan gereserveerd voor herstel en uitnodigingen", 'admin.mail_destination_daily_cap': "Berichten per dag naar één ontvanger", 'admin.mail_destination_cooldown_seconds': "Seconden tussen berichten naar één ontvanger", + 'admin.mail_invite_link_daily_cap': 'E-mails met uitnodigingslink per dag per account', 'admin.mail_verification_resend_cooldown': "Seconden tussen twee registratiecodes", 'admin.mail_reset_cooldown': "Seconden tussen twee herstelcodes", 'admin.mail_email_change_cooldown': "Seconden voordat een account een ander adres mag voorstellen", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 1e29669..886f1c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -564,6 +564,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "Z tego zarezerwowane na resety i zaproszenia", 'admin.mail_destination_daily_cap': "Wiadomości dziennie do jednego odbiorcy", 'admin.mail_destination_cooldown_seconds': "Sekundy między wiadomościami do jednego odbiorcy", + 'admin.mail_invite_link_daily_cap': 'E-maile z linkiem zaproszenia dziennie na konto', 'admin.mail_verification_resend_cooldown': "Sekundy między dwoma kodami rejestracji", 'admin.mail_reset_cooldown': "Sekundy między dwoma kodami resetu", 'admin.mail_email_change_cooldown': "Sekundy, zanim konto może zaproponować inny adres", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index a0ae557..643548a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -550,6 +550,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "Destas, reservadas para redefinições e convites", 'admin.mail_destination_daily_cap': "Mensagens por dia para um mesmo destinatário", 'admin.mail_destination_cooldown_seconds': "Segundos entre mensagens ao mesmo destinatário", + 'admin.mail_invite_link_daily_cap': 'E-mails de link de convite por dia por conta', 'admin.mail_verification_resend_cooldown': "Segundos entre dois códigos de cadastro", 'admin.mail_reset_cooldown': "Segundos entre dois códigos de redefinição", 'admin.mail_email_change_cooldown': "Segundos até uma conta poder propor outro endereço", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 8d3e426..17adb8b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -537,6 +537,7 @@ export default { 'admin.mail_hourly_reserved_for_recovery': "其中为重置与邀请保留", 'admin.mail_destination_daily_cap': "每日发往同一收件人的邮件数", 'admin.mail_destination_cooldown_seconds': "发往同一收件人的间隔(秒)", + 'admin.mail_invite_link_daily_cap': '每个账户每天的邀请链接邮件数', 'admin.mail_verification_resend_cooldown': "两次注册验证码的间隔(秒)", 'admin.mail_reset_cooldown': "两次重置验证码的间隔(秒)", 'admin.mail_email_change_cooldown': "账号可再次申请其他地址前的秒数", diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py index 1ef7796..b0ef4d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py +++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py @@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from meshbay_hub.db.models import EmailVerification, Group, IPLog, User +from meshbay_hub.db.models import EmailVerification, Group, GroupInviteLink, IPLog, User log = logging.getLogger(__name__) @@ -42,6 +42,17 @@ async def purge_stale_pending_users(db: AsyncSession, return result.rowcount +async def purge_invite_links(db: AsyncSession) -> int: + """Unused invitation links once expired; used ones after a month.""" + from meshbay_hub.api.invite_links import KEEP_REDEEMED + now = datetime.now(UTC) + result = await db.execute(delete(GroupInviteLink).where( + (GroupInviteLink.redeemed_by.is_(None) & (GroupInviteLink.expires_at <= now)) + | (GroupInviteLink.redeemed_at < now - KEEP_REDEEMED))) + await db.commit() + return result.rowcount + + async def cleanup_loop(get_session): """Run cleanup once at startup, then every 24 hours.""" try: @@ -71,6 +82,9 @@ async def cleanup_loop(get_session): throttled = await login_throttle.purge_expired(db) if throttled: log.info("Purged %d expired sign-in counters", throttled) + links = await purge_invite_links(db) + if links: + log.info("Purged %d spent or expired invitation links", links) except asyncio.CancelledError: raise except Exception as e: -- cgit v1.2.3