diff --git a/server/migrations/versions/b4c7e8f9a012_add_email_transcript_to_to_room.py b/server/migrations/versions/b4c7e8f9a012_add_email_transcript_to_to_room.py
new file mode 100644
index 00000000..e3e94ea5
--- /dev/null
+++ b/server/migrations/versions/b4c7e8f9a012_add_email_transcript_to_to_room.py
@@ -0,0 +1,28 @@
+"""add email_transcript_to to room
+
+Revision ID: b4c7e8f9a012
+Revises: a2b3c4d5e6f7
+Create Date: 2026-03-24 00:00:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "b4c7e8f9a012"
+down_revision: Union[str, None] = "a2b3c4d5e6f7"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "room",
+ sa.Column("email_transcript_to", sa.String(), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("room", "email_transcript_to")
diff --git a/server/reflector/app.py b/server/reflector/app.py
index 4b8243bd..1dd88c48 100644
--- a/server/reflector/app.py
+++ b/server/reflector/app.py
@@ -13,6 +13,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
from reflector.logger import logger
from reflector.metrics import metrics_init
from reflector.settings import settings
+from reflector.views.config import router as config_router
from reflector.views.daily import router as daily_router
from reflector.views.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router
@@ -107,6 +108,7 @@ app.include_router(transcripts_process_router, prefix="/v1")
app.include_router(user_router, prefix="/v1")
app.include_router(user_api_keys_router, prefix="/v1")
app.include_router(user_ws_router, prefix="/v1")
+app.include_router(config_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1")
app.include_router(daily_router, prefix="/v1/daily")
diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py
index 92ac5eac..80922af9 100644
--- a/server/reflector/db/rooms.py
+++ b/server/reflector/db/rooms.py
@@ -63,6 +63,7 @@ rooms = sqlalchemy.Table(
nullable=False,
server_default=sqlalchemy.sql.false(),
),
+ sqlalchemy.Column("email_transcript_to", sqlalchemy.String, nullable=True),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -92,6 +93,7 @@ class Room(BaseModel):
ics_last_etag: str | None = None
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
skip_consent: bool = False
+ email_transcript_to: str | None = None
class RoomController:
@@ -147,6 +149,7 @@ class RoomController:
ics_enabled: bool = False,
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
skip_consent: bool = False,
+ email_transcript_to: str | None = None,
):
"""
Add a new room
@@ -172,6 +175,7 @@ class RoomController:
"ics_enabled": ics_enabled,
"platform": platform,
"skip_consent": skip_consent,
+ "email_transcript_to": email_transcript_to,
}
room = Room(**room_data)
diff --git a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py
index 5fe36b96..35a1003b 100644
--- a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py
+++ b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py
@@ -1501,13 +1501,30 @@ async def send_email(input: PipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
- if not meeting or not meeting.email_recipients:
+ recipients = (
+ list(meeting.email_recipients)
+ if meeting and meeting.email_recipients
+ else []
+ )
+
+ # Also check room-level email
+ from reflector.db.rooms import rooms_controller # noqa: PLC0415
+
+ if transcript.room_id:
+ room = await rooms_controller.get_by_id(transcript.room_id)
+ if room and room.email_transcript_to:
+ if room.email_transcript_to not in recipients:
+ recipients.append(room.email_transcript_to)
+
+ if not recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
- await transcripts_controller.update(transcript, {"share_mode": "public"})
+ # For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
+ if meeting and meeting.email_recipients:
+ await transcripts_controller.update(transcript, {"share_mode": "public"})
- count = await send_transcript_email(meeting.email_recipients, transcript)
+ count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
diff --git a/server/reflector/hatchet/workflows/file_pipeline.py b/server/reflector/hatchet/workflows/file_pipeline.py
index 4b0b528e..5bd5caed 100644
--- a/server/reflector/hatchet/workflows/file_pipeline.py
+++ b/server/reflector/hatchet/workflows/file_pipeline.py
@@ -896,14 +896,30 @@ async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
- if not meeting or not meeting.email_recipients:
+ recipients = (
+ list(meeting.email_recipients)
+ if meeting and meeting.email_recipients
+ else []
+ )
+
+ # Also check room-level email
+ from reflector.db.rooms import rooms_controller # noqa: PLC0415
+
+ if transcript.room_id:
+ room = await rooms_controller.get_by_id(transcript.room_id)
+ if room and room.email_transcript_to:
+ if room.email_transcript_to not in recipients:
+ recipients.append(room.email_transcript_to)
+
+ if not recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
- # Set transcript to public so the link works for anyone
- await transcripts_controller.update(transcript, {"share_mode": "public"})
+ # For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
+ if meeting and meeting.email_recipients:
+ await transcripts_controller.update(transcript, {"share_mode": "public"})
- count = await send_transcript_email(meeting.email_recipients, transcript)
+ count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
diff --git a/server/reflector/hatchet/workflows/live_post_pipeline.py b/server/reflector/hatchet/workflows/live_post_pipeline.py
index 2de144df..e1768835 100644
--- a/server/reflector/hatchet/workflows/live_post_pipeline.py
+++ b/server/reflector/hatchet/workflows/live_post_pipeline.py
@@ -397,13 +397,30 @@ async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
- if not meeting or not meeting.email_recipients:
+ recipients = (
+ list(meeting.email_recipients)
+ if meeting and meeting.email_recipients
+ else []
+ )
+
+ # Also check room-level email
+ from reflector.db.rooms import rooms_controller # noqa: PLC0415
+
+ if transcript.room_id:
+ room = await rooms_controller.get_by_id(transcript.room_id)
+ if room and room.email_transcript_to:
+ if room.email_transcript_to not in recipients:
+ recipients.append(room.email_transcript_to)
+
+ if not recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
- await transcripts_controller.update(transcript, {"share_mode": "public"})
+ # For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
+ if meeting and meeting.email_recipients:
+ await transcripts_controller.update(transcript, {"share_mode": "public"})
- count = await send_transcript_email(meeting.email_recipients, transcript)
+ count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
diff --git a/server/reflector/storage/base.py b/server/reflector/storage/base.py
index ba4316d8..9b8f2965 100644
--- a/server/reflector/storage/base.py
+++ b/server/reflector/storage/base.py
@@ -116,9 +116,12 @@ class Storage:
expires_in: int = 3600,
*,
bucket: str | None = None,
+ extra_params: dict | None = None,
) -> str:
"""Generate presigned URL. bucket: override instance default if provided."""
- return await self._get_file_url(filename, operation, expires_in, bucket=bucket)
+ return await self._get_file_url(
+ filename, operation, expires_in, bucket=bucket, extra_params=extra_params
+ )
async def _get_file_url(
self,
@@ -127,6 +130,7 @@ class Storage:
expires_in: int = 3600,
*,
bucket: str | None = None,
+ extra_params: dict | None = None,
) -> str:
raise NotImplementedError
diff --git a/server/reflector/storage/storage_aws.py b/server/reflector/storage/storage_aws.py
index 66d7ccae..02f9bf1e 100644
--- a/server/reflector/storage/storage_aws.py
+++ b/server/reflector/storage/storage_aws.py
@@ -170,16 +170,23 @@ class AwsStorage(Storage):
expires_in: int = 3600,
*,
bucket: str | None = None,
+ extra_params: dict | None = None,
) -> str:
actual_bucket = bucket or self._bucket_name
folder = self.aws_folder
s3filename = f"{folder}/{filename}" if folder else filename
+ params = {}
+ if extra_params:
+ params.update(extra_params)
+ # Always set Bucket/Key after extra_params to prevent overrides
+ params["Bucket"] = actual_bucket
+ params["Key"] = s3filename
async with self.session.client(
"s3", config=self.boto_config, endpoint_url=self._endpoint_url
) as client:
presigned_url = await client.generate_presigned_url(
operation,
- Params={"Bucket": actual_bucket, "Key": s3filename},
+ Params=params,
ExpiresIn=expires_in,
)
diff --git a/server/reflector/views/config.py b/server/reflector/views/config.py
new file mode 100644
index 00000000..6a8ed87a
--- /dev/null
+++ b/server/reflector/views/config.py
@@ -0,0 +1,20 @@
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from reflector.email import is_email_configured
+from reflector.settings import settings
+
+router = APIRouter()
+
+
+class ConfigResponse(BaseModel):
+ zulip_enabled: bool
+ email_enabled: bool
+
+
+@router.get("/config", response_model=ConfigResponse)
+async def get_config():
+ return ConfigResponse(
+ zulip_enabled=bool(settings.ZULIP_REALM),
+ email_enabled=is_email_configured(),
+ )
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index 33ec2061..7a51ef41 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -44,6 +44,7 @@ class Room(BaseModel):
ics_last_etag: Optional[str] = None
platform: Platform
skip_consent: bool = False
+ email_transcript_to: str | None = None
class RoomDetails(Room):
@@ -93,6 +94,7 @@ class CreateRoom(BaseModel):
ics_enabled: bool = False
platform: Platform
skip_consent: bool = False
+ email_transcript_to: str | None = None
class UpdateRoom(BaseModel):
@@ -112,6 +114,7 @@ class UpdateRoom(BaseModel):
ics_enabled: Optional[bool] = None
platform: Optional[Platform] = None
skip_consent: Optional[bool] = None
+ email_transcript_to: Optional[str] = None
class CreateRoomMeeting(BaseModel):
@@ -253,6 +256,7 @@ async def rooms_create(
ics_enabled=room.ics_enabled,
platform=room.platform,
skip_consent=room.skip_consent,
+ email_transcript_to=room.email_transcript_to,
)
diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py
index 33342578..c33b1cb0 100644
--- a/server/reflector/views/transcripts.py
+++ b/server/reflector/views/transcripts.py
@@ -40,6 +40,7 @@ from reflector.db.transcripts import (
transcripts_controller,
)
from reflector.db.users import user_controller
+from reflector.email import is_email_configured, send_transcript_email
from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.processors.types import Word
from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment
@@ -718,3 +719,31 @@ async def transcript_post_to_zulip(
await transcripts_controller.update(
transcript, {"zulip_message_id": response["id"]}
)
+
+
+class SendEmailRequest(BaseModel):
+ email: str
+
+
+class SendEmailResponse(BaseModel):
+ sent: int
+
+
+@router.post("/transcripts/{transcript_id}/email", response_model=SendEmailResponse)
+async def transcript_send_email(
+ transcript_id: str,
+ request: SendEmailRequest,
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
+):
+ if not is_email_configured():
+ raise HTTPException(status_code=400, detail="Email not configured")
+ user_id = user["sub"]
+ transcript = await transcripts_controller.get_by_id_for_http(
+ transcript_id, user_id=user_id
+ )
+ if not transcript:
+ raise HTTPException(status_code=404, detail="Transcript not found")
+ if not transcripts_controller.user_can_mutate(transcript, user_id):
+ raise HTTPException(status_code=403, detail="Not authorized")
+ sent = await send_transcript_email([request.email], transcript)
+ return SendEmailResponse(sent=sent)
diff --git a/server/reflector/views/transcripts_audio.py b/server/reflector/views/transcripts_audio.py
index f6dc2c2c..badd1cf9 100644
--- a/server/reflector/views/transcripts_audio.py
+++ b/server/reflector/views/transcripts_audio.py
@@ -53,9 +53,22 @@ async def transcript_get_audio_mp3(
else:
user_id = token_user["sub"]
- transcript = await transcripts_controller.get_by_id_for_http(
- transcript_id, user_id=user_id
- )
+ if not user_id and not token:
+ # No authentication provided at all. Only anonymous transcripts
+ # (user_id=None) are accessible without auth, to preserve
+ # pipeline access via _generate_local_audio_link().
+ transcript = await transcripts_controller.get_by_id(transcript_id)
+ if not transcript or transcript.deleted_at is not None:
+ raise HTTPException(status_code=404, detail="Transcript not found")
+ if transcript.user_id is not None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Authentication required",
+ )
+ else:
+ transcript = await transcripts_controller.get_by_id_for_http(
+ transcript_id, user_id=user_id
+ )
if transcript.audio_location == "storage":
# proxy S3 file, to prevent issue with CORS
@@ -94,16 +107,16 @@ async def transcript_get_audio_mp3(
request,
transcript.audio_mp3_filename,
content_type="audio/mpeg",
- content_disposition=f"attachment; filename={filename}",
+ content_disposition=f"inline; filename={filename}",
)
@router.get("/transcripts/{transcript_id}/audio/waveform")
async def transcript_get_audio_waveform(
transcript_id: str,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> AudioWaveform:
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
diff --git a/server/reflector/views/transcripts_video.py b/server/reflector/views/transcripts_video.py
index aa698b1c..a85c5741 100644
--- a/server/reflector/views/transcripts_video.py
+++ b/server/reflector/views/transcripts_video.py
@@ -2,16 +2,14 @@
Transcript cloud video endpoint — returns a presigned URL for streaming playback.
"""
-from typing import Annotated, Optional
+from typing import Annotated
-import jwt
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.meetings import meetings_controller
from reflector.db.transcripts import transcripts_controller
-from reflector.settings import settings
from reflector.storage import get_source_storage
router = APIRouter()
@@ -30,26 +28,9 @@ class VideoUrlResponse(BaseModel):
)
async def transcript_get_video_url(
transcript_id: str,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
- token: str | None = None,
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
- user_id = user["sub"] if user else None
- if not user_id and token:
- try:
- token_user = await auth.verify_raw_token(token)
- except Exception:
- token_user = None
- if not token_user:
- try:
- payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
- user_id = payload.get("sub")
- except jwt.PyJWTError:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid or expired token",
- )
- else:
- user_id = token_user["sub"]
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
@@ -66,7 +47,11 @@ async def transcript_get_video_url(
url = await source_storage.get_file_url(
meeting.daily_composed_video_s3_key,
operation="get_object",
- expires_in=3600,
+ expires_in=900,
+ extra_params={
+ "ResponseContentDisposition": "inline",
+ "ResponseContentType": "video/mp4",
+ },
)
return VideoUrlResponse(
diff --git a/server/tests/test_pipeline_main_file.py b/server/tests/test_pipeline_main_file.py
index 342e8288..e9fd9397 100644
--- a/server/tests/test_pipeline_main_file.py
+++ b/server/tests/test_pipeline_main_file.py
@@ -137,6 +137,7 @@ async def mock_storage():
operation: str = "get_object",
expires_in: int = 3600,
bucket=None,
+ extra_params=None,
):
return f"http://test-storage/{path}"
diff --git a/server/tests/test_security_permissions.py b/server/tests/test_security_permissions.py
index 83d03223..bf8e048b 100644
--- a/server/tests/test_security_permissions.py
+++ b/server/tests/test_security_permissions.py
@@ -373,9 +373,9 @@ async def test_audio_mp3_requires_token_for_owned_transcript(
tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(audio_path, tr.audio_mp3_filename)
- # Anonymous GET without token should be 403 or 404 depending on access; we call mp3
+ # Anonymous GET without token should be 401 (auth required)
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
- assert resp.status_code == 403
+ assert resp.status_code == 401
# With token should succeed
token = create_access_token(
@@ -898,7 +898,7 @@ async def test_anonymous_transcript_in_list_when_public_mode(client, monkeypatch
@pytest.mark.asyncio
async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir):
"""Anonymous transcript audio (mp3) is accessible without authentication
- because user_id=None bypasses share_mode checks."""
+ because user_id=None bypasses the auth requirement (pipeline access)."""
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
@@ -920,7 +920,7 @@ async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert (
resp.status_code == 200
- ), f"Anonymous transcript audio should be accessible: {resp.text}"
+ ), f"Anonymous transcript audio should be accessible for pipeline: {resp.text}"
@pytest.mark.asyncio
diff --git a/server/tests/test_transcripts_audio_download.py b/server/tests/test_transcripts_audio_download.py
index 3990c553..132b43e8 100644
--- a/server/tests/test_transcripts_audio_download.py
+++ b/server/tests/test_transcripts_audio_download.py
@@ -40,7 +40,7 @@ async def fake_transcript(tmpdir, client, monkeypatch):
],
)
async def test_transcript_audio_download(
- fake_transcript, url_suffix, content_type, client
+ authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200
@@ -61,7 +61,7 @@ async def test_transcript_audio_download(
],
)
async def test_transcript_audio_download_head(
- fake_transcript, url_suffix, content_type, client
+ authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200
@@ -82,7 +82,7 @@ async def test_transcript_audio_download_head(
],
)
async def test_transcript_audio_download_range(
- fake_transcript, url_suffix, content_type, client
+ authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
@@ -102,7 +102,7 @@ async def test_transcript_audio_download_range(
],
)
async def test_transcript_audio_download_range_with_seek(
- fake_transcript, url_suffix, content_type, client
+ authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
diff --git a/server/tests/test_transcripts_audio_token_auth.py b/server/tests/test_transcripts_audio_token_auth.py
index 0cc11dd5..8fb30171 100644
--- a/server/tests/test_transcripts_audio_token_auth.py
+++ b/server/tests/test_transcripts_audio_token_auth.py
@@ -98,10 +98,10 @@ async def private_transcript(tmpdir):
@pytest.mark.asyncio
-async def test_audio_mp3_private_no_auth_returns_403(private_transcript, client):
- """Without auth, accessing a private transcript's audio returns 403."""
+async def test_audio_mp3_private_no_auth_returns_401(private_transcript, client):
+ """Without auth, accessing a private transcript's audio returns 401."""
response = await client.get(f"/transcripts/{private_transcript.id}/audio/mp3")
- assert response.status_code == 403
+ assert response.status_code == 401
@pytest.mark.asyncio
@@ -125,8 +125,8 @@ async def test_audio_mp3_with_bearer_header(private_transcript, client):
@pytest.mark.asyncio
-async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
- """Public transcripts are accessible without any auth."""
+async def test_audio_mp3_public_transcript_no_auth_returns_401(tmpdir, client):
+ """Public transcripts require authentication for audio access."""
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.settings import settings
@@ -146,8 +146,7 @@ async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
shutil.copy(mp3_source, audio_filename)
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
- assert response.status_code == 200
- assert response.headers["content-type"] == "audio/mpeg"
+ assert response.status_code == 401
# ---------------------------------------------------------------------------
@@ -299,11 +298,9 @@ async def test_local_audio_link_token_works_with_authentik_backend(
"""_generate_local_audio_link creates an HS256 token via create_access_token.
When the Authentik (RS256) auth backend is active, verify_raw_token uses
- JWTAuth which expects RS256 + public key. The HS256 token created by
- _generate_local_audio_link will fail verification, returning 401.
-
- This test documents the bug: the internal audio URL generated for the
- diarization pipeline is unusable under the JWT auth backend.
+ JWTAuth which expects RS256 + public key. The HS256 token fails RS256
+ verification, but the audio endpoint's HS256 fallback (jwt.decode with
+ SECRET_KEY) correctly handles it, so the request succeeds with 200.
"""
from urllib.parse import parse_qs, urlparse
@@ -322,6 +319,55 @@ async def test_local_audio_link_token_works_with_authentik_backend(
f"/transcripts/{private_transcript.id}/audio/mp3?token={token}"
)
- # BUG: this should be 200 (the token was created by our own server),
- # but the Authentik backend rejects it because it's HS256, not RS256.
+ # The HS256 fallback in the audio endpoint handles this correctly.
assert response.status_code == 200
+
+
+# ---------------------------------------------------------------------------
+# Waveform endpoint auth tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_waveform_requires_authentication(client):
+ """Waveform endpoint returns 401 for unauthenticated requests."""
+ response = await client.get("/transcripts/any-id/audio/waveform")
+ assert response.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_audio_mp3_authenticated_user_accesses_anonymous_transcript(
+ tmpdir, client
+):
+ """Authenticated user can access audio for an anonymous (user_id=None) transcript."""
+ from reflector.app import app
+ from reflector.auth import current_user, current_user_optional
+ from reflector.db.transcripts import SourceKind, transcripts_controller
+ from reflector.settings import settings
+
+ settings.DATA_DIR = Path(tmpdir)
+
+ transcript = await transcripts_controller.add(
+ "Anonymous audio test",
+ source_kind=SourceKind.FILE,
+ user_id=None,
+ share_mode="private",
+ )
+ await transcripts_controller.update(transcript, {"status": "ended"})
+
+ audio_filename = transcript.audio_mp3_filename
+ mp3_source = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
+ audio_filename.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy(mp3_source, audio_filename)
+
+ _user = lambda: {"sub": "some-authenticated-user", "email": "user@example.com"}
+ app.dependency_overrides[current_user] = _user
+ app.dependency_overrides[current_user_optional] = _user
+ try:
+ response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
+ finally:
+ del app.dependency_overrides[current_user]
+ del app.dependency_overrides[current_user_optional]
+
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "audio/mpeg"
diff --git a/server/tests/test_transcripts_video.py b/server/tests/test_transcripts_video.py
index f01fd1b5..f04913c9 100644
--- a/server/tests/test_transcripts_video.py
+++ b/server/tests/test_transcripts_video.py
@@ -103,3 +103,162 @@ async def test_transcript_get_includes_video_fields(authenticated_client, client
data = response.json()
assert data["has_cloud_video"] is False
assert data["cloud_video_duration"] is None
+
+
+@pytest.mark.asyncio
+async def test_video_url_requires_authentication(client):
+ """Test that video URL endpoint returns 401 for unauthenticated requests."""
+ response = await client.get("/transcripts/any-id/video/url")
+ assert response.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_video_url_presigned_params(authenticated_client, client):
+ """Test that presigned URL is generated with short expiry and inline disposition."""
+ from reflector.db import get_database
+ from reflector.db.meetings import meetings
+
+ meeting_id = "test-meeting-presigned-params"
+ await get_database().execute(
+ meetings.insert().values(
+ id=meeting_id,
+ room_name="Presigned Params Meeting",
+ room_url="https://example.com",
+ host_room_url="https://example.com/host",
+ start_date=datetime.now(timezone.utc),
+ end_date=datetime.now(timezone.utc) + timedelta(hours=1),
+ room_id=None,
+ daily_composed_video_s3_key="recordings/video.mp4",
+ daily_composed_video_duration=60,
+ )
+ )
+
+ transcript = await transcripts_controller.add(
+ name="presigned-params",
+ source_kind=SourceKind.ROOM,
+ meeting_id=meeting_id,
+ user_id="randomuserid",
+ )
+
+ with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
+ mock_instance = AsyncMock()
+ mock_instance.get_file_url = AsyncMock(
+ return_value="https://s3.example.com/presigned-url"
+ )
+ mock_storage.return_value = mock_instance
+
+ await client.get(f"/transcripts/{transcript.id}/video/url")
+
+ mock_instance.get_file_url.assert_called_once_with(
+ "recordings/video.mp4",
+ operation="get_object",
+ expires_in=900,
+ extra_params={
+ "ResponseContentDisposition": "inline",
+ "ResponseContentType": "video/mp4",
+ },
+ )
+
+
+async def _create_meeting_with_video(meeting_id):
+ """Helper to create a meeting with cloud video."""
+ from reflector.db import get_database
+ from reflector.db.meetings import meetings
+
+ await get_database().execute(
+ meetings.insert().values(
+ id=meeting_id,
+ room_name="Video Meeting",
+ room_url="https://example.com",
+ host_room_url="https://example.com/host",
+ start_date=datetime.now(timezone.utc),
+ end_date=datetime.now(timezone.utc) + timedelta(hours=1),
+ room_id=None,
+ daily_composed_video_s3_key="recordings/video.mp4",
+ daily_composed_video_duration=60,
+ )
+ )
+
+
+@pytest.mark.asyncio
+async def test_video_url_private_transcript_denies_non_owner(
+ authenticated_client, client
+):
+ """Authenticated non-owner cannot access video for a private transcript."""
+ meeting_id = "test-meeting-private-deny"
+ await _create_meeting_with_video(meeting_id)
+
+ transcript = await transcripts_controller.add(
+ name="private-video",
+ source_kind=SourceKind.ROOM,
+ meeting_id=meeting_id,
+ user_id="other-owner",
+ share_mode="private",
+ )
+
+ with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
+ mock_instance = AsyncMock()
+ mock_instance.get_file_url = AsyncMock(
+ return_value="https://s3.example.com/url"
+ )
+ mock_storage.return_value = mock_instance
+
+ response = await client.get(f"/transcripts/{transcript.id}/video/url")
+
+ assert response.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_video_url_public_transcript_allows_authenticated_non_owner(
+ authenticated_client, client
+):
+ """Authenticated non-owner can access video for a public transcript."""
+ meeting_id = "test-meeting-public-allow"
+ await _create_meeting_with_video(meeting_id)
+
+ transcript = await transcripts_controller.add(
+ name="public-video",
+ source_kind=SourceKind.ROOM,
+ meeting_id=meeting_id,
+ user_id="other-owner",
+ share_mode="public",
+ )
+
+ with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
+ mock_instance = AsyncMock()
+ mock_instance.get_file_url = AsyncMock(
+ return_value="https://s3.example.com/url"
+ )
+ mock_storage.return_value = mock_instance
+
+ response = await client.get(f"/transcripts/{transcript.id}/video/url")
+
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_video_url_semi_private_allows_authenticated_non_owner(
+ authenticated_client, client
+):
+ """Authenticated non-owner can access video for a semi-private transcript."""
+ meeting_id = "test-meeting-semi-private-allow"
+ await _create_meeting_with_video(meeting_id)
+
+ transcript = await transcripts_controller.add(
+ name="semi-private-video",
+ source_kind=SourceKind.ROOM,
+ meeting_id=meeting_id,
+ user_id="other-owner",
+ share_mode="semi-private",
+ )
+
+ with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
+ mock_instance = AsyncMock()
+ mock_instance.get_file_url = AsyncMock(
+ return_value="https://s3.example.com/url"
+ )
+ mock_storage.return_value = mock_instance
+
+ response = await client.get(f"/transcripts/{transcript.id}/video/url")
+
+ assert response.status_code == 200
diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx
index e5349bab..7c81f3fe 100644
--- a/www/app/(app)/rooms/page.tsx
+++ b/www/app/(app)/rooms/page.tsx
@@ -31,6 +31,7 @@ import {
useZulipTopics,
useRoomGet,
useRoomTestWebhook,
+ useConfig,
} from "../../lib/apiHooks";
import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination";
@@ -92,6 +93,7 @@ const roomInitialState = {
icsFetchInterval: 5,
platform: "whereby",
skipConsent: false,
+ emailTranscriptTo: "",
};
export default function RoomsList() {
@@ -133,11 +135,15 @@ export default function RoomsList() {
null,
);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
+ const [emailTranscriptEnabled, setEmailTranscriptEnabled] = useState(false);
const createRoomMutation = useRoomCreate();
const updateRoomMutation = useRoomUpdate();
const deleteRoomMutation = useRoomDelete();
- const { data: streams = [] } = useZulipStreams();
+ const { data: config } = useConfig();
+ const zulipEnabled = config?.zulip_enabled ?? false;
+ const emailEnabled = config?.email_enabled ?? false;
+ const { data: streams = [] } = useZulipStreams(zulipEnabled);
const { data: topics = [] } = useZulipTopics(selectedStreamId);
const {
@@ -177,6 +183,7 @@ export default function RoomsList() {
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
platform: detailedEditedRoom.platform,
skipConsent: detailedEditedRoom.skip_consent || false,
+ emailTranscriptTo: detailedEditedRoom.email_transcript_to || "",
}
: null,
[detailedEditedRoom],
@@ -329,6 +336,7 @@ export default function RoomsList() {
ics_fetch_interval: room.icsFetchInterval,
platform,
skip_consent: room.skipConsent,
+ email_transcript_to: room.emailTranscriptTo || null,
};
if (isEditing) {
@@ -369,6 +377,7 @@ export default function RoomsList() {
// Reset states
setShowWebhookSecret(false);
setWebhookTestResult(null);
+ setEmailTranscriptEnabled(!!roomData.email_transcript_to);
setRoomInput({
name: roomData.name,
@@ -392,6 +401,7 @@ export default function RoomsList() {
icsFetchInterval: roomData.ics_fetch_interval || 5,
platform: roomData.platform,
skipConsent: roomData.skip_consent || false,
+ emailTranscriptTo: roomData.email_transcript_to || "",
});
setEditRoomId(roomId);
setIsEditing(true);
@@ -469,6 +479,7 @@ export default function RoomsList() {
setNameError("");
setShowWebhookSecret(false);
setWebhookTestResult(null);
+ setEmailTranscriptEnabled(false);
onOpen();
}}
>
@@ -504,7 +515,9 @@ export default function RoomsList() {
General
Calendar
- Share
+ {(zulipEnabled || emailEnabled) && (
+ Share
+ )}
WebHook
@@ -831,96 +844,144 @@ export default function RoomsList() {
-
- {
- const syntheticEvent = {
- target: {
- name: "zulipAutoPost",
- type: "checkbox",
- checked: e.checked,
- },
- };
- handleRoomChange(syntheticEvent);
- }}
- >
-
-
-
-
-
- Automatically post transcription to Zulip
-
-
-
-
- Zulip stream
-
- setRoomInput({
- ...room,
- zulipStream: e.value[0],
- zulipTopic: "",
- })
- }
- collection={streamCollection}
- disabled={!room.zulipAutoPost}
- >
-
-
-
-
-
-
-
-
-
-
-
- {streamOptions.map((option) => (
-
- {option.label}
-
-
- ))}
-
-
-
-
-
- Zulip topic
-
- setRoomInput({ ...room, zulipTopic: e.value[0] })
- }
- collection={topicCollection}
- disabled={!room.zulipAutoPost}
- >
-
-
-
-
-
-
-
-
-
-
-
- {topicOptions.map((option) => (
-
- {option.label}
-
-
- ))}
-
-
-
-
+ {emailEnabled && (
+ <>
+
+ {
+ setEmailTranscriptEnabled(!!e.checked);
+ if (!e.checked) {
+ setRoomInput({
+ ...room,
+ emailTranscriptTo: "",
+ });
+ }
+ }}
+ >
+
+
+
+
+
+ Email me transcript when processed
+
+
+
+ {emailTranscriptEnabled && (
+
+
+
+ Transcript will be emailed to this address after
+ processing
+
+
+ )}
+ >
+ )}
+ {zulipEnabled && (
+ <>
+
+ {
+ const syntheticEvent = {
+ target: {
+ name: "zulipAutoPost",
+ type: "checkbox",
+ checked: e.checked,
+ },
+ };
+ handleRoomChange(syntheticEvent);
+ }}
+ >
+
+
+
+
+
+ Automatically post transcription to Zulip
+
+
+
+
+ Zulip stream
+
+ setRoomInput({
+ ...room,
+ zulipStream: e.value[0],
+ zulipTopic: "",
+ })
+ }
+ collection={streamCollection}
+ disabled={!room.zulipAutoPost}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {streamOptions.map((option) => (
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+ Zulip topic
+
+ setRoomInput({
+ ...room,
+ zulipTopic: e.value[0],
+ })
+ }
+ collection={topicCollection}
+ disabled={!room.zulipAutoPost}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {topicOptions.map((option) => (
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+ >
+ )}
diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx
index b02f77c7..be06f642 100644
--- a/www/app/(app)/transcripts/[transcriptId]/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx
@@ -24,6 +24,8 @@ import {
} from "@chakra-ui/react";
import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
+import { useAuth } from "../../../lib/AuthProvider";
+import { featureEnabled } from "../../../lib/features";
type TranscriptDetails = {
params: Promise<{
@@ -57,7 +59,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
const [finalSummaryElement, setFinalSummaryElement] =
useState(null);
- const hasCloudVideo = !!transcript.data?.has_cloud_video;
+ const auth = useAuth();
+ const isAuthenticated =
+ auth.status === "authenticated" || !featureEnabled("requireLogin");
+ const hasCloudVideo = !!transcript.data?.has_cloud_video && isAuthenticated;
const [videoExpanded, setVideoExpanded] = useState(false);
const [videoNewBadge, setVideoNewBadge] = useState(() => {
if (typeof window === "undefined") return true;
@@ -145,7 +150,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
mt={4}
mb={4}
>
- {!mp3.audioDeleted && (
+ {isAuthenticated && !mp3.audioDeleted && (
<>
{waveform.waveform && mp3.media && topics.topics ? (
l.value && l.value !== "NOTRANSLATION",
+);
+
const TranscriptCreate = () => {
const router = useRouter();
const auth = useAuth();
@@ -33,8 +37,13 @@ const TranscriptCreate = () => {
const nameChange = (event: React.ChangeEvent) => {
setName(event.target.value);
};
+ const [sourceLanguage, setSourceLanguage] = useState("");
const [targetLanguage, setTargetLanguage] = useState("NOTRANSLATION");
+ const onSourceLanguageChange = (newval) => {
+ (!newval || typeof newval === "string") &&
+ setSourceLanguage(newval || "en");
+ };
const onLanguageChange = (newval) => {
(!newval || typeof newval === "string") && setTargetLanguage(newval);
};
@@ -55,7 +64,7 @@ const TranscriptCreate = () => {
const targetLang = getTargetLanguage();
createTranscript.create({
name,
- source_language: "en",
+ source_language: sourceLanguage || "en",
target_language: targetLang || "en",
source_kind: "live",
});
@@ -67,7 +76,7 @@ const TranscriptCreate = () => {
const targetLang = getTargetLanguage();
createTranscript.create({
name,
- source_language: "en",
+ source_language: sourceLanguage || "en",
target_language: targetLang || "en",
source_kind: "file",
});
@@ -160,6 +169,15 @@ const TranscriptCreate = () => {
placeholder="Optional"
/>
+
+ Audio language
+
+
Do you want to enable live translation?
{
const selectedOption = shareOptionsData.find(
@@ -169,14 +173,20 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
Share options
-
- {requireLogin && (
+
+ {requireLogin && zulipEnabled && (
)}
+ {emailEnabled && (
+
+ )}
{
+ if (!email) return;
+ try {
+ await sendEmailMutation.mutateAsync({
+ params: {
+ path: { transcript_id: props.transcript.id },
+ },
+ body: { email },
+ });
+ setSent(true);
+ setTimeout(() => {
+ setSent(false);
+ setShowModal(false);
+ setEmail("");
+ }, 2000);
+ } catch (error) {
+ console.error("Error sending email:", error);
+ }
+ };
+
+ return (
+ <>
+
+
+ {
+ setShowModal(e.open);
+ if (!e.open) {
+ setSent(false);
+ setEmail("");
+ }
+ }}
+ size="md"
+ >
+
+
+
+
+ Send Transcript via Email
+
+
+
+
+
+ {sent ? (
+ Email sent successfully!
+ ) : (
+
+
+ Enter the email address to send this transcript to:
+
+ setEmail(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSend()}
+ />
+
+ )}
+
+
+
+ {!sent && (
+
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/www/app/(app)/transcripts/useMp3.ts b/www/app/(app)/transcripts/useMp3.ts
index fef630c3..a93bbc75 100644
--- a/www/app/(app)/transcripts/useMp3.ts
+++ b/www/app/(app)/transcripts/useMp3.ts
@@ -56,7 +56,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
useEffect(() => {
- if (!transcriptId || later || !transcript) return;
+ if (!transcriptId || later || !transcript || !accessTokenInfo) return;
let stopped = false;
let audioElement: HTMLAudioElement | null = null;
@@ -113,7 +113,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
if (handleError) audioElement.removeEventListener("error", handleError);
}
};
- }, [transcriptId, transcript, later]);
+ }, [transcriptId, transcript, later, accessTokenInfo]);
const getNow = () => {
setLater(false);
diff --git a/www/app/(app)/transcripts/videoPlayer.tsx b/www/app/(app)/transcripts/videoPlayer.tsx
index e68933d9..05fe4a1a 100644
--- a/www/app/(app)/transcripts/videoPlayer.tsx
+++ b/www/app/(app)/transcripts/videoPlayer.tsx
@@ -39,17 +39,16 @@ export default function VideoPlayer({
setLoading(true);
setError(null);
try {
- const params = new URLSearchParams();
- if (accessToken) {
- params.set("token", accessToken);
- }
- const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url?${params}`;
+ const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url`;
const headers: Record = {};
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`;
}
const resp = await fetch(url, { headers });
if (!resp.ok) {
+ if (resp.status === 401) {
+ throw new Error("Sign in to view the video recording");
+ }
throw new Error("Failed to load video");
}
const data = await resp.json();
@@ -90,7 +89,7 @@ export default function VideoPlayer({
w="fit-content"
maxW="100%"
>
- Failed to load video recording
+ {error || "Failed to load video recording"}
);
}
@@ -132,10 +131,14 @@ export default function VideoPlayer({
{/* Video element with visible controls */}
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}