aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py336
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b2c3d4e5f6a7_add_group_invite_links.py46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py54
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py16
20 files changed, 507 insertions, 8 deletions
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: