diff --git a/backend/alembic/versions/008_add_meeting_participants.py b/backend/alembic/versions/008_add_meeting_participants.py new file mode 100644 index 0000000..2593257 --- /dev/null +++ b/backend/alembic/versions/008_add_meeting_participants.py @@ -0,0 +1,39 @@ +"""add meeting participants + +Revision ID: 008_add_meeting_participants +Revises: 007_add_assignees +Create Date: 2026-02-10 17:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "008_add_meeting_participants" +down_revision = "007_add_assignees" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "meeting_participants", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("meeting_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("email", sa.String(length=200), nullable=False), + sa.Column("source", sa.String(length=50), nullable=True, server_default="manual"), + sa.Column("created_at", sa.DateTime(), nullable=True, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["meeting_id"], ["meetings.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("meeting_id", "email", name="uq_meeting_participant_email"), + ) + op.create_index("ix_meeting_participants_meeting_id", "meeting_participants", ["meeting_id"]) + op.create_index("ix_meeting_participants_email", "meeting_participants", ["email"]) + + +def downgrade(): + op.drop_index("ix_meeting_participants_email", table_name="meeting_participants") + op.drop_index("ix_meeting_participants_meeting_id", table_name="meeting_participants") + op.drop_table("meeting_participants") diff --git a/backend/alembic/versions/integrated_all_models.py b/backend/alembic/versions/integrated_all_models.py index bc1912a..6fe2f51 100644 --- a/backend/alembic/versions/integrated_all_models.py +++ b/backend/alembic/versions/integrated_all_models.py @@ -213,6 +213,21 @@ def upgrade(): ) op.create_index('idx_reminder_scheduled_status', 'meeting_reminders', ['scheduled_at', 'status']) + if not _table_exists(conn, 'meeting_participants'): + op.create_table( + 'meeting_participants', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('meeting_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(200), nullable=False), + sa.Column('email', sa.String(200), nullable=False), + sa.Column('source', sa.String(50), nullable=True, server_default='manual'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['meeting_id'], ['meetings.id'], ondelete='CASCADE'), + sa.UniqueConstraint('meeting_id', 'email', name='uq_meeting_participant_email'), + ) + op.create_index('ix_meeting_participants_meeting_id', 'meeting_participants', ['meeting_id']) + op.create_index('ix_meeting_participants_email', 'meeting_participants', ['email']) + # seed default community and admin # insert only if not exists users = conn.execute(sa.text("SELECT id FROM users WHERE username = :u OR email = :e"), {"u": "admin", "e": "admin@example.com"}).fetchone() @@ -270,6 +285,7 @@ def downgrade(): # drop tables in reverse order if exist for t in [ + 'meeting_participants', 'meeting_reminders', 'meetings', 'committee_members', diff --git a/backend/app/api/communities.py b/backend/app/api/communities.py index 49a4cd3..ecdb609 100644 --- a/backend/app/api/communities.py +++ b/backend/app/api/communities.py @@ -1,7 +1,8 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session +from pydantic import BaseModel +from sqlalchemy.orm import Session, attributes from sqlalchemy import update, select, insert from app.core.dependencies import get_current_user, get_current_active_superuser @@ -17,10 +18,16 @@ CommunityMemberAdd, UserBrief, ) +from app.schemas.email import EmailSettings, EmailSettingsOut + router = APIRouter() +class TestEmailRequest(BaseModel): + to_email: str + + @router.get("", response_model=List[CommunityBrief]) def list_communities( current_user: User = Depends(get_current_user), @@ -437,3 +444,176 @@ def update_user_role( db.commit() return {"message": f"User role updated to {role} successfully"} + + +@router.get("/{community_id}/email-settings", response_model=EmailSettingsOut) +def get_email_settings( + community_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get email settings for a community.""" + community = db.query(Community).filter(Community.id == community_id).first() + if not community: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Community not found", + ) + + # Check if user has access to this community + if not current_user.is_superuser and community not in current_user.communities: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + + settings_data = community.settings or {} + email_config = settings_data.get("email") if isinstance(settings_data, dict) else None + + if not email_config: + # Return default empty settings + return EmailSettingsOut( + enabled=False, + provider="smtp", + from_email="", + smtp={} + ) + + # Return all SMTP config including password (admin has permission to view) + smtp_config = email_config.get("smtp", {}) + if not isinstance(smtp_config, dict): + smtp_config = {} + + return EmailSettingsOut( + enabled=email_config.get("enabled", False), + provider=email_config.get("provider", "smtp"), + from_email=email_config.get("from_email", ""), + from_name=email_config.get("from_name"), + reply_to=email_config.get("reply_to"), + smtp=smtp_config + ) + + +@router.put("/{community_id}/email-settings") +def update_email_settings( + community_id: int, + settings: EmailSettings, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Update email settings for a community. Admin only.""" + community = db.query(Community).filter(Community.id == community_id).first() + if not community: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Community not found", + ) + + # Check if user is admin of this community or superuser + is_admin = False + if current_user.is_superuser: + is_admin = True + else: + # Check community_users role + result = db.execute( + select(community_users.c.role) + .where(community_users.c.user_id == current_user.id) + .where(community_users.c.community_id == community_id) + ).first() + if result and result[0] == 'admin': + is_admin = True + + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + + # Update settings + current_settings = community.settings or {} + if not isinstance(current_settings, dict): + current_settings = {} + + current_settings["email"] = settings.model_dump() + community.settings = current_settings + # Mark the JSON field as modified so SQLAlchemy detects the change + attributes.flag_modified(community, "settings") + + db.commit() + db.refresh(community) + + return {"message": "Email settings updated successfully"} + + +@router.post("/{community_id}/email-settings/test") +def test_email_settings( + community_id: int, + request_data: TestEmailRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Test email settings by sending a test email. Admin only.""" + to_email = request_data.to_email + if not to_email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="to_email is required", + ) + + community = db.query(Community).filter(Community.id == community_id).first() + if not community: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Community not found", + ) + + # Check if user is admin of this community or superuser + is_admin = False + if current_user.is_superuser: + is_admin = True + else: + # Check community_users role + result = db.execute( + select(community_users.c.role) + .where(community_users.c.user_id == current_user.id) + .where(community_users.c.community_id == community_id) + ).first() + if result and result[0] == 'admin': + is_admin = True + + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + + # Send test email + from app.services.email import send_email, EmailMessage, get_sender_info, get_smtp_config + + smtp_config, email_cfg = get_smtp_config(community) + if not smtp_config: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="SMTP not configured", + ) + + from_email, from_name, reply_to = get_sender_info(community, email_cfg) + + message = EmailMessage( + subject=f"[{community.name}] Email Configuration Test", + to_emails=[to_email], + html_body="

Email Test Successful!

Your SMTP configuration is working correctly.

", + text_body="Email Test Successful!\n\nYour SMTP configuration is working correctly.", + from_email=from_email, + from_name=from_name, + reply_to=reply_to, + ) + + try: + send_email(community, message) + return {"message": "Test email sent successfully"} + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to send email: {str(e)}", + ) diff --git a/backend/app/api/meetings.py b/backend/app/api/meetings.py index 9160e6c..e8d70b1 100644 --- a/backend/app/api/meetings.py +++ b/backend/app/api/meetings.py @@ -8,14 +8,18 @@ from app.core.dependencies import get_current_community, get_community_admin from app.database import get_db from app.models import User -from app.models.meeting import Meeting, MeetingReminder -from app.models.committee import Committee +from app.models.meeting import Meeting, MeetingReminder, MeetingParticipant +from app.models.committee import Committee, CommitteeMember from app.schemas.governance import ( MeetingCreate, MeetingUpdate, MeetingOut, MeetingDetail, + MeetingReminderCreate, MeetingReminderOut, + MeetingParticipantCreate, + MeetingParticipantOut, + MeetingParticipantImportResult, ) router = APIRouter() @@ -269,7 +273,7 @@ def delete_meeting( @router.post("/{meeting_id}/reminders", response_model=MeetingReminderOut, status_code=status.HTTP_201_CREATED) def create_reminder( meeting_id: int, - reminder_type: str, + reminder_data: MeetingReminderCreate, community_id: int = Depends(get_current_community), current_user: User = Depends(get_community_admin), db: Session = Depends(get_db), @@ -278,7 +282,7 @@ def create_reminder( 为会议创建提醒记录。 实际的通知发送依赖角色4的通知服务(预留)。 - reminder_type: 'preparation', 'one_week', 'three_days', 'one_day', 'two_hours' + reminder_type: 'preparation', 'one_week', 'three_days', 'one_day', 'two_hours', 'immediate' """ # 验证会议存在 meeting = db.query(Meeting).filter( @@ -294,16 +298,23 @@ def create_reminder( # 计算提醒时间 from datetime import timedelta + reminder_type = reminder_data.reminder_type hours_map = { 'preparation': meeting.reminder_before_hours or 24, 'one_week': 168, 'three_days': 72, 'one_day': 24, 'two_hours': 2, + 'immediate': 0, # 立即发送 } hours_before = hours_map.get(reminder_type, 24) - scheduled_at = meeting.scheduled_at - timedelta(hours=hours_before) + + # For immediate reminders, set scheduled_at to now + if reminder_type == 'immediate': + scheduled_at = datetime.now() + else: + scheduled_at = meeting.scheduled_at - timedelta(hours=hours_before) reminder = MeetingReminder( meeting_id=meeting_id, @@ -317,6 +328,17 @@ def create_reminder( db.commit() db.refresh(reminder) + # If immediate, trigger sending right away + if reminder_type == 'immediate': + from app.services.notification import send_meeting_reminder + try: + send_meeting_reminder(db, reminder.id) + db.refresh(reminder) + except Exception as e: + # Even if sending fails, we still return the reminder + # The error will be recorded in the reminder status + pass + return reminder @@ -349,6 +371,176 @@ def list_reminders( return reminders +@router.get("/{meeting_id}/participants", response_model=List[MeetingParticipantOut]) +def list_participants( + meeting_id: int, + community_id: int = Depends(get_current_community), + db: Session = Depends(get_db), +): + """列出会议的与会人。""" + meeting = db.query(Meeting).filter( + Meeting.id == meeting_id, + Meeting.community_id == community_id, + ).first() + + if not meeting: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="会议不存在", + ) + + participants = ( + db.query(MeetingParticipant) + .filter(MeetingParticipant.meeting_id == meeting_id) + .order_by(MeetingParticipant.created_at.asc()) + .all() + ) + + return participants + + +@router.post("/{meeting_id}/participants", response_model=MeetingParticipantOut, status_code=status.HTTP_201_CREATED) +def add_participant( + meeting_id: int, + data: MeetingParticipantCreate, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_community_admin), + db: Session = Depends(get_db), +): + """手动添加会议与会人。""" + meeting = db.query(Meeting).filter( + Meeting.id == meeting_id, + Meeting.community_id == community_id, + ).first() + + if not meeting: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="会议不存在", + ) + + existing = db.query(MeetingParticipant).filter( + MeetingParticipant.meeting_id == meeting_id, + MeetingParticipant.email == data.email, + ).first() + + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="该邮箱已在与会人列表中", + ) + + participant = MeetingParticipant( + meeting_id=meeting_id, + name=data.name, + email=data.email, + source="manual", + ) + + db.add(participant) + db.commit() + db.refresh(participant) + + return participant + + +@router.delete("/{meeting_id}/participants/{participant_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_participant( + meeting_id: int, + participant_id: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_community_admin), + db: Session = Depends(get_db), +): + """删除会议与会人。""" + participant = ( + db.query(MeetingParticipant) + .join(Meeting, MeetingParticipant.meeting_id == Meeting.id) + .filter( + MeetingParticipant.id == participant_id, + MeetingParticipant.meeting_id == meeting_id, + Meeting.community_id == community_id, + ) + .first() + ) + + if not participant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="与会人不存在", + ) + + db.delete(participant) + db.commit() + + return None + + +@router.post("/{meeting_id}/participants/import", response_model=MeetingParticipantImportResult) +def import_participants( + meeting_id: int, + community_id: int = Depends(get_current_community), + current_user: User = Depends(get_community_admin), + db: Session = Depends(get_db), +): + """从委员会成员导入会议与会人。""" + meeting = db.query(Meeting).filter( + Meeting.id == meeting_id, + Meeting.community_id == community_id, + ).first() + + if not meeting: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="会议不存在", + ) + + members = db.query(CommitteeMember).filter( + CommitteeMember.committee_id == meeting.committee_id, + CommitteeMember.is_active.is_(True), + CommitteeMember.email.isnot(None), + CommitteeMember.email != "", + ).all() + + # Get existing participant emails to avoid duplicates + existing_emails = set( + email for (email,) in db.query(MeetingParticipant.email) + .filter(MeetingParticipant.meeting_id == meeting_id) + .all() + ) + + imported = 0 + skipped = 0 + participants = [] + + for member in members: + if member.email in existing_emails: + skipped += 1 + continue + + participant = MeetingParticipant( + meeting_id=meeting_id, + name=member.name, + email=member.email, + source="committee_import", + ) + db.add(participant) + participants.append(participant) + existing_emails.add(member.email) # Prevent duplicates within this batch + imported += 1 + + if participants: + db.commit() + for participant in participants: + db.refresh(participant) + + return MeetingParticipantImportResult( + imported=imported, + skipped=skipped, + participants=participants, + ) + + @router.get("/{meeting_id}/minutes") def get_meeting_minutes( meeting_id: int, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d8b92f0..8e0bfad 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,7 +6,7 @@ from app.models.publish_record import PublishRecord from app.models.password_reset import PasswordResetToken from app.models.committee import Committee, CommitteeMember -from app.models.meeting import Meeting, MeetingReminder +from app.models.meeting import Meeting, MeetingReminder, MeetingParticipant __all__ = [ "User", @@ -21,4 +21,5 @@ "CommitteeMember", "Meeting", "MeetingReminder", + "MeetingParticipant", ] diff --git a/backend/app/models/meeting.py b/backend/app/models/meeting.py index b4e4b48..bf24fea 100644 --- a/backend/app/models/meeting.py +++ b/backend/app/models/meeting.py @@ -2,7 +2,7 @@ from sqlalchemy import ( Column, Integer, String, Text, Boolean, DateTime, JSON, - ForeignKey, Index, Table, + ForeignKey, Index, Table, UniqueConstraint, ) from sqlalchemy.orm import relationship @@ -81,7 +81,11 @@ class Meeting(Base): secondaryjoin="User.id == meeting_assignees.c.user_id", back_populates="assigned_meetings", ) - created_by = relationship("User") + participants = relationship( + "MeetingParticipant", + back_populates="meeting", + cascade="all, delete-orphan", + ) class MeetingReminder(Base): @@ -115,3 +119,27 @@ class MeetingReminder(Base): __table_args__ = ( Index("idx_reminder_scheduled_status", "scheduled_at", "status"), ) + + +class MeetingParticipant(Base): + """会议与会人""" + __tablename__ = "meeting_participants" + + id = Column(Integer, primary_key=True, index=True) + meeting_id = Column( + Integer, + ForeignKey("meetings.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + name = Column(String(200), nullable=False) + email = Column(String(200), nullable=False, index=True) + source = Column(String(50), default="manual") # manual / committee_import + created_at = Column(DateTime, default=datetime.utcnow) + + meeting = relationship("Meeting", back_populates="participants") + + __table_args__ = ( + UniqueConstraint("meeting_id", "email", name="uq_meeting_participant_email"), + ) diff --git a/backend/app/schemas/email.py b/backend/app/schemas/email.py new file mode 100644 index 0000000..7573788 --- /dev/null +++ b/backend/app/schemas/email.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class EmailSmtpConfig(BaseModel): + host: str = Field(..., min_length=1, max_length=200, description="SMTP server hostname") + port: int = Field(465, ge=1, le=65535, description="SMTP port: 465 for SSL/TLS, 587 for STARTTLS") + username: str = Field("", max_length=200, description="Username (usually the email address, leave empty to use from_email)") + password: str = Field("", max_length=200, description="SMTP password") + use_tls: bool = Field(True, description="Use STARTTLS for port 587 (port 465 uses SSL by default)") + + +class EmailSettings(BaseModel): + enabled: bool = True + provider: str = "smtp" + from_email: str = Field(..., min_length=1, max_length=200) + from_name: Optional[str] = Field(None, max_length=200) + reply_to: Optional[str] = Field(None, max_length=200) + smtp: EmailSmtpConfig + + +class EmailSettingsOut(BaseModel): + enabled: bool + provider: str + from_email: str + from_name: Optional[str] = None + reply_to: Optional[str] = None + smtp: dict # Without password diff --git a/backend/app/schemas/governance.py b/backend/app/schemas/governance.py index 5d3a161..f9b6f93 100644 --- a/backend/app/schemas/governance.py +++ b/backend/app/schemas/governance.py @@ -180,8 +180,34 @@ class MeetingMinutesUpdate(BaseModel): minutes: str +class MeetingParticipantCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + email: str = Field(..., min_length=1, max_length=200) + + +class MeetingParticipantOut(BaseModel): + id: int + meeting_id: int + name: str + email: str + source: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class MeetingParticipantImportResult(BaseModel): + imported: int + skipped: int + participants: list[MeetingParticipantOut] = [] + + # ==================== MeetingReminder Schemas ==================== +class MeetingReminderCreate(BaseModel): + reminder_type: str = Field(..., description="Reminder type: preparation, one_week, three_days, one_day, two_hours, immediate") + + class MeetingReminderOut(BaseModel): id: int meeting_id: int diff --git a/backend/app/services/email.py b/backend/app/services/email.py new file mode 100644 index 0000000..758d4af --- /dev/null +++ b/backend/app/services/email.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from dataclasses import dataclass +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email import encoders +import smtplib +from typing import Iterable + +from app.config import settings +from app.models.community import Community + + +@dataclass +class EmailAttachment: + filename: str + content: bytes + mime_type: str + + +@dataclass +class EmailMessage: + subject: str + to_emails: list[str] + html_body: str + text_body: str + from_email: str + from_name: str | None = None + reply_to: str | None = None + attachments: list[EmailAttachment] | None = None + + +@dataclass +class SmtpConfig: + host: str + port: int + username: str + password: str + use_tls: bool + + +class SmtpEmailProvider: + def __init__(self, config: SmtpConfig) -> None: + self._config = config + + def send(self, message: EmailMessage) -> None: + msg = MIMEMultipart("mixed") + msg["Subject"] = message.subject + msg["From"] = self._format_from(message.from_email, message.from_name) + msg["To"] = ", ".join(message.to_emails) + if message.reply_to: + msg["Reply-To"] = message.reply_to + + alternative = MIMEMultipart("alternative") + alternative.attach(MIMEText(message.text_body, "plain", "utf-8")) + alternative.attach(MIMEText(message.html_body, "html", "utf-8")) + msg.attach(alternative) + + for attachment in message.attachments or []: + part = MIMEBase(*attachment.mime_type.split("/", 1)) + part.set_payload(attachment.content) + encoders.encode_base64(part) + part.add_header("Content-Disposition", f'attachment; filename="{attachment.filename}"') + msg.attach(part) + + # Auto-detect encryption method based on port + # Port 465: SMTP_SSL (direct SSL/TLS connection) + # Port 587/others: SMTP with STARTTLS (upgrade after connect) + use_ssl = self._config.port == 465 + # Use username if provided, otherwise use from_email as username + username = self._config.username.strip() if self._config.username else message.from_email + + if use_ssl: + # Use SMTP_SSL for port 465 + with smtplib.SMTP_SSL(self._config.host, self._config.port, timeout=30) as server: + if username and self._config.password: + server.login(username, self._config.password) + server.sendmail(message.from_email, message.to_emails, msg.as_string()) + else: + # Use SMTP with STARTTLS for port 587 and others + with smtplib.SMTP(self._config.host, self._config.port, timeout=30) as server: + if self._config.use_tls: + server.starttls() + if username and self._config.password: + server.login(username, self._config.password) + server.sendmail(message.from_email, message.to_emails, msg.as_string()) + + @staticmethod + def _format_from(email: str, name: str | None) -> str: + if name: + return f"{name} <{email}>" + return email + + +def _load_email_settings(community: Community) -> dict: + settings_data = community.settings or {} + email_cfg = settings_data.get("email") if isinstance(settings_data, dict) else None + return email_cfg or {} + + +def _get_fallback_smtp_config() -> SmtpConfig | None: + if not settings.SMTP_HOST: + return None + return SmtpConfig( + host=settings.SMTP_HOST, + port=settings.SMTP_PORT, + username=settings.SMTP_USER, + password=settings.SMTP_PASSWORD, + use_tls=settings.SMTP_USE_TLS, + ) + + +def get_smtp_config(community: Community) -> tuple[SmtpConfig | None, dict]: + email_cfg = _load_email_settings(community) + if email_cfg.get("enabled") is False: + return None, email_cfg + + smtp_cfg = email_cfg.get("smtp") if isinstance(email_cfg, dict) else None + if isinstance(smtp_cfg, dict) and smtp_cfg.get("host"): + config = SmtpConfig( + host=str(smtp_cfg.get("host")), + port=int(smtp_cfg.get("port", 587)), + username=str(smtp_cfg.get("username", "")), + password=str(smtp_cfg.get("password", "")), + use_tls=bool(smtp_cfg.get("use_tls", True)), + ) + return config, email_cfg + + return _get_fallback_smtp_config(), email_cfg + + +def get_sender_info(community: Community, email_cfg: dict) -> tuple[str, str | None, str | None]: + from_email = email_cfg.get("from_email") or settings.SMTP_FROM_EMAIL or settings.SMTP_USER + from_name = email_cfg.get("from_name") or community.name + reply_to = email_cfg.get("reply_to") + return from_email, from_name, reply_to + + +def send_email(community: Community, message: EmailMessage) -> None: + smtp_config, _ = get_smtp_config(community) + if not smtp_config: + raise ValueError("SMTP 未配置或已禁用") + + provider = SmtpEmailProvider(smtp_config) + provider.send(message) diff --git a/backend/app/services/ics.py b/backend/app/services/ics.py new file mode 100644 index 0000000..bfc6814 --- /dev/null +++ b/backend/app/services/ics.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +from app.models.meeting import Meeting +from app.models.community import Community + + +def _format_dt(dt: datetime) -> str: + return dt.strftime("%Y%m%dT%H%M%S") + + +def build_meeting_ics(meeting: Meeting, community: Community, organizer_email: str) -> bytes: + dt_start = meeting.scheduled_at + dt_end = meeting.scheduled_at + timedelta(minutes=meeting.duration or 0) + + description_parts = [] + if meeting.description: + description_parts.append(meeting.description) + if meeting.agenda: + description_parts.append(f"Agenda:\n{meeting.agenda}") + if meeting.location: + description_parts.append(f"Location: {meeting.location}") + + description = "\n\n".join(description_parts) if description_parts else "Meeting reminder" + location = meeting.location or "" + uid = f"meeting-{meeting.id}@{community.slug}" + dtstamp = _format_dt(datetime.utcnow()) + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//openGecko//Meeting//EN", + "CALSCALE:GREGORIAN", + "METHOD:REQUEST", + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{dtstamp}", + f"DTSTART:{_format_dt(dt_start)}", + f"DTEND:{_format_dt(dt_end)}", + f"SUMMARY:{meeting.title}", + f"LOCATION:{location}", + f"DESCRIPTION:{_escape_text(description)}", + f"ORGANIZER:MAILTO:{organizer_email}", + "STATUS:CONFIRMED", + "END:VEVENT", + "END:VCALENDAR", + ] + + return "\r\n".join(lines).encode("utf-8") + + +def _escape_text(value: str) -> str: + """Escape special characters for iCalendar text fields.""" + # Order matters: escape backslash first + return ( + value.replace("\\", "\\\\") + .replace(",", "\\,") + .replace(";", "\\;") + .replace("\n", "\\n") + .replace("\r", "") + ) diff --git a/backend/app/services/notification.py b/backend/app/services/notification.py new file mode 100644 index 0000000..f6dae6f --- /dev/null +++ b/backend/app/services/notification.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import smtplib +from datetime import datetime +from html import escape + +from sqlalchemy.orm import Session + +from app.models.meeting import MeetingReminder, MeetingParticipant, Meeting +from app.models.community import Community +from app.services.email import EmailAttachment, EmailMessage, get_sender_info, send_email, get_smtp_config +from app.services.ics import build_meeting_ics + + +def send_meeting_reminder(db: Session, reminder_id: int) -> MeetingReminder: + reminder = db.query(MeetingReminder).filter(MeetingReminder.id == reminder_id).first() + if not reminder: + raise ValueError("Reminder not found") + + if reminder.status == "sent": + return reminder + + meeting = db.query(Meeting).filter(Meeting.id == reminder.meeting_id).first() + if not meeting: + reminder.status = "failed" + reminder.error_message = "Meeting not found" + db.commit() + return reminder + + community = db.query(Community).filter(Community.id == meeting.community_id).first() + if not community: + reminder.status = "failed" + reminder.error_message = "Community not found" + db.commit() + return reminder + + participants = db.query(MeetingParticipant).filter( + MeetingParticipant.meeting_id == meeting.id + ).all() + recipient_emails = [p.email for p in participants if p.email] + + if not recipient_emails: + reminder.status = "failed" + reminder.error_message = "No recipients" + db.commit() + return reminder + + smtp_config, email_cfg = get_smtp_config(community) + if not smtp_config: + reminder.status = "failed" + reminder.error_message = "SMTP not configured" + db.commit() + return reminder + + from_email, from_name, reply_to = get_sender_info(community, email_cfg) + + subject = f"[{meeting.title}] Meeting reminder" + text_body = _build_text_body(meeting, community) + html_body = _build_html_body(meeting, community) + ics_content = build_meeting_ics(meeting, community, organizer_email=from_email) + + message = EmailMessage( + subject=subject, + to_emails=recipient_emails, + html_body=html_body, + text_body=text_body, + from_email=from_email, + from_name=from_name, + reply_to=reply_to, + attachments=[ + EmailAttachment( + filename="meeting.ics", + content=ics_content, + mime_type="text/calendar", + ) + ], + ) + + try: + send_email(community, message) + reminder.status = "sent" + reminder.sent_at = datetime.utcnow() + reminder.error_message = None + except smtplib.SMTPException as exc: + reminder.status = "failed" + reminder.error_message = f"SMTP error: {str(exc)}" + except Exception as exc: + reminder.status = "failed" + reminder.error_message = f"Error: {str(exc)}" + + db.commit() + return reminder + + +def _build_text_body(meeting: Meeting, community: Community) -> str: + parts = [ + f"Community: {community.name}", + f"Title: {meeting.title}", + f"Time: {meeting.scheduled_at.strftime('%Y-%m-%d %H:%M')}", + f"Duration: {meeting.duration} minutes", + ] + if meeting.location: + parts.append(f"Location: {meeting.location}") + if meeting.agenda: + parts.append(f"Agenda:\n{meeting.agenda}") + return "\n".join(parts) + + +def _build_html_body(meeting: Meeting, community: Community) -> str: + title = escape(meeting.title) + community_name = escape(community.name) + location = escape(meeting.location or "") + agenda = escape(meeting.agenda or "") + time_str = meeting.scheduled_at.strftime("%Y-%m-%d %H:%M") + + return ( + "" + f"

{title}

" + f"

Community: {community_name}

" + f"

Time: {time_str}

" + f"

Duration: {meeting.duration} minutes

" + f"

Location: {location}

" + f"

Agenda:

" + f"
{agenda}
" + "" + ) diff --git a/frontend/src/api/community.ts b/frontend/src/api/community.ts index 307a7ce..6721bee 100644 --- a/frontend/src/api/community.ts +++ b/frontend/src/api/community.ts @@ -84,3 +84,55 @@ export async function updateUserRole( params: { role }, }) } + +// Email Settings Types +export interface EmailSmtpConfig { + host: string + port: number + username: string + password?: string + use_tls: boolean +} + +export interface EmailSettings { + enabled: boolean + provider: string + from_email: string + from_name?: string + reply_to?: string + smtp: EmailSmtpConfig +} + +export interface EmailSettingsOut { + enabled: boolean + provider: string + from_email: string + from_name?: string + reply_to?: string + smtp: Record +} + +export async function getEmailSettings(communityId: number): Promise { + const { data } = await apiClient.get( + `/communities/${communityId}/email-settings` + ) + return data +} + +export async function updateEmailSettings( + communityId: number, + settings: EmailSettings +): Promise { + await apiClient.put(`/communities/${communityId}/email-settings`, settings) +} + +export async function testEmailSettings( + communityId: number, + toEmail: string +): Promise<{ message: string }> { + const { data } = await apiClient.post<{ message: string }>( + `/communities/${communityId}/email-settings/test`, + { to_email: toEmail } + ) + return data +} diff --git a/frontend/src/api/governance.ts b/frontend/src/api/governance.ts index 17ecb4c..1f014a6 100644 --- a/frontend/src/api/governance.ts +++ b/frontend/src/api/governance.ts @@ -250,3 +250,44 @@ export async function createMeetingReminder(meetingId: number, reminderType: str const { data } = await apiClient.post(`/meetings/${meetingId}/reminders`, { reminder_type: reminderType }) return data } + +// ==================== Meeting Participant APIs ==================== + +export interface MeetingParticipant { + id: number + meeting_id: number + name: string + email: string + source: string + created_at: string +} + +export interface MeetingParticipantCreate { + name: string + email: string +} + +export interface MeetingParticipantImportResult { + total_imported: number + skipped_count: number + added_count: number +} + +export async function listMeetingParticipants(meetingId: number) { + const { data } = await apiClient.get(`/meetings/${meetingId}/participants`) + return data +} + +export async function addMeetingParticipant(meetingId: number, participant: MeetingParticipantCreate) { + const { data } = await apiClient.post(`/meetings/${meetingId}/participants`, participant) + return data +} + +export async function deleteMeetingParticipant(meetingId: number, participantId: number) { + await apiClient.delete(`/meetings/${meetingId}/participants/${participantId}`) +} + +export async function importParticipantsFromCommittee(meetingId: number) { + const { data } = await apiClient.post(`/meetings/${meetingId}/participants/import`) + return data +} diff --git a/frontend/src/views/CommitteeMemberManage.vue b/frontend/src/views/CommitteeMemberManage.vue index f4258e8..a59ea44 100644 --- a/frontend/src/views/CommitteeMemberManage.vue +++ b/frontend/src/views/CommitteeMemberManage.vue @@ -36,7 +36,7 @@ -
+ + + + + + + + + + SMTP 配置 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 常用端口:465 (SSL/TLS) 或 587 (STARTTLS) +
+
+ + +
+ + + 使用发件人邮箱 ({{ emailForm.from_email }}) + +
+
+ 大多数邮箱服务的用户名就是完整邮箱地址,留空则自动使用上面的发件人邮箱 +
+
+ + + +
+ + +
+ 部分邮箱需要使用专用密码而非登录密码 +
+
+
+ + + + SSL/TLS (端口465,推荐) + STARTTLS (端口587) + +
+ 端口465会自动使用SSL加密连接 +
+
+
+ +
@@ -278,7 +395,12 @@ import { addUserToCommunity, removeUserFromCommunity, updateUserRole, + getEmailSettings, + updateEmailSettings, + testEmailSettings, type CommunityUser, + type EmailSettings, + type EmailSettingsOut, } from '../api/community' import { listChannels, @@ -362,6 +484,52 @@ function isSecretMasked(val: string | undefined): boolean { return !!val && val.startsWith('••••') } +// ── Email settings ────────────────────────────────────────────────── +const emailSettingsVisible = ref(false) +const emailFormRef = ref() +const emailSaving = ref(false) +const emailTesting = ref(false) +const emailForm = ref({ + enabled: false, + provider: 'smtp', + from_email: '', + from_name: '', + reply_to: '', + smtp: { + host: '', + port: 465, + username: '', + password: '', + use_tls: true, + }, +}) + +const selectedEmailTemplate = ref('') + +// Email service templates +const emailTemplates: Record = { + feishu: { host: 'smtp.feishu.cn', port: 465, desc: '飞书邮箱' }, + qq: { host: 'smtp.qq.com', port: 465, desc: 'QQ邮箱' }, + '163': { host: 'smtp.163.com', port: 465, desc: '163邮箱' }, + gmail: { host: 'smtp.gmail.com', port: 587, desc: 'Gmail' }, + outlook: { host: 'smtp.office365.com', port: 587, desc: 'Outlook' }, +} + +function applyEmailTemplate() { + const template = emailTemplates[selectedEmailTemplate.value] + if (template) { + emailForm.value.smtp.host = template.host + emailForm.value.smtp.port = template.port + emailForm.value.smtp.use_tls = template.port === 587 + ElMessage.success(`已应用${template.desc}配置模板`) + } +} + +function handlePortChange(port: number) { + // Auto set use_tls based on port + emailForm.value.smtp.use_tls = port === 587 +} + function handleSecretFocus(field: string) { if (isSecretMasked(channelForm.value.config[field])) { channelForm.value.config[field] = '' @@ -527,6 +695,9 @@ async function handleCommunityAction(command: string, community: Community) { await loadChannels() channelsDialogVisible.value = true break + case 'email': + await showEmailSettingsDialog(community) + break case 'members': await showMembersDialog(community) break @@ -602,6 +773,121 @@ async function handleRoleChange(user: CommunityUser, newRole: string) { } } +// ── Email Settings ────────────────────────────────────────────────── +async function showEmailSettingsDialog(community: Community) { + selectedCommunity.value = community + selectedEmailTemplate.value = '' + try { + const settings = await getEmailSettings(community.id) + emailForm.value = { + enabled: settings.enabled, + provider: settings.provider || 'smtp', + from_email: settings.from_email || '', + from_name: settings.from_name || '', + reply_to: settings.reply_to || '', + smtp: { + host: settings.smtp?.host || '', + port: settings.smtp?.port || 465, + username: settings.smtp?.username || '', + password: settings.smtp?.password || '', + use_tls: settings.smtp?.use_tls !== undefined ? settings.smtp.use_tls : true, + }, + } + } catch (e: any) { + ElMessage.error('加载邮件设置失败') + // Set default values + emailForm.value = { + enabled: false, + provider: 'smtp', + from_email: '', + from_name: '', + reply_to: '', + smtp: { + host: '', + port: 465, + username: '', + password: '', + use_tls: true, + }, + } + } + emailSettingsVisible.value = true +} + +async function handleSaveEmailSettings() { + if (!selectedCommunity.value) return + + // Validate password + if (!emailForm.value.smtp.password) { + ElMessage.warning('请填写SMTP密码') + return + } + + emailSaving.value = true + try { + const settingsToSave: EmailSettings = { ...emailForm.value } + // Enable email settings when saving + settingsToSave.enabled = true + // If username is empty, use from_email + if (!settingsToSave.smtp.username || !settingsToSave.smtp.username.trim()) { + settingsToSave.smtp.username = settingsToSave.from_email + } + + await updateEmailSettings(selectedCommunity.value.id, settingsToSave) + ElMessage.success('邮件设置已保存') + emailSettingsVisible.value = false + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '保存失败') + } finally { + emailSaving.value = false + } +} + +async function handleTestEmailSettings() { + if (!selectedCommunity.value) return + + // Validate required fields + if (!emailForm.value.from_email) { + ElMessage.warning('请填写发件人邮箱') + return + } + if (!emailForm.value.smtp.host) { + ElMessage.warning('请填写SMTP服务器') + return + } + // Check if password is configured + if (!emailForm.value.smtp.password || emailForm.value.smtp.password.trim() === '') { + ElMessage.warning('请填写SMTP密码') + return + } + + emailTesting.value = true + try { + // Save settings first (temporarily) + const settingsToSave: EmailSettings = { ...emailForm.value } + // Enable SMTP for testing + settingsToSave.enabled = true + // If username is empty, it will use from_email on backend + if (!settingsToSave.smtp.username || !settingsToSave.smtp.username.trim()) { + settingsToSave.smtp.username = settingsToSave.from_email + } + + await updateEmailSettings(selectedCommunity.value.id, settingsToSave) + + // Test by sending to the from_email address + const result = await testEmailSettings( + selectedCommunity.value.id, + emailForm.value.from_email + ) + + ElMessage.success(`测试邮件已发送到 ${emailForm.value.from_email},请检查收件箱`) + } catch (e: any) { + ElMessage.error('测试失败:' + (e?.response?.data?.detail || e.message || '网络错误')) + } finally { + emailTesting.value = false + } +} + onMounted(loadCommunities) diff --git a/frontend/src/views/ContentCalendar.vue b/frontend/src/views/ContentCalendar.vue index 19a650c..2d1213e 100644 --- a/frontend/src/views/ContentCalendar.vue +++ b/frontend/src/views/ContentCalendar.vue @@ -776,6 +776,7 @@ onBeforeUnmount(() => { .item-author { font-size: 11px; color: #999; + } } } } diff --git a/frontend/src/views/MeetingDetail.vue b/frontend/src/views/MeetingDetail.vue index 65991fd..0006002 100644 --- a/frontend/src/views/MeetingDetail.vue +++ b/frontend/src/views/MeetingDetail.vue @@ -73,15 +73,15 @@ -
+
-
+ -
+ + + + + + + + + + + + + + + + + + +

将从此会议所属的委员会中导入所有活跃成员(仅导入有邮箱的成员)

+

已存在的参与者将自动跳过

+
+ +
@@ -197,14 +305,22 @@ import { Calendar, Clock, Location, - Plus + Plus, + Download, + Message, + Warning } from '@element-plus/icons-vue' import { getMeeting, updateMeetingMinutes, listMeetingReminders, createMeetingReminder, + listMeetingParticipants, + addMeetingParticipant, + deleteMeetingParticipant, + importParticipantsFromCommittee, type MeetingDetail, + type MeetingParticipant, type MeetingReminder } from '@/api/governance' import { useUserStore } from '@/stores/user' @@ -231,10 +347,24 @@ const minutesForm = ref({ const showReminderDialog = ref(false) const reminderType = ref('one_day') +// Participants +const participants = ref([]) +const loadingParticipants = ref(false) +const showAddParticipantDialog = ref(false) +const showImportDialog = ref(false) +const addingParticipant = ref(false) +const importing = ref(false) +const sendingCalendar = ref(false) +const participantForm = ref({ + name: '', + email: '' +}) + onMounted(() => { loadMeeting() if (isAdmin.value) { loadReminders() + loadParticipants() } }) @@ -314,6 +444,84 @@ async function createReminder() { } } +// ── Participants Functions ────────────────────────────────────────── +async function loadParticipants() { + if (!meeting.value) return + + loadingParticipants.value = true + try { + participants.value = await listMeetingParticipants(meeting.value.id) + } catch (error: any) { + ElMessage.error('加载参与者列表失败') + } finally { + loadingParticipants.value = false + } +} + +async function addParticipant() { + if (!meeting.value || !participantForm.value.name || !participantForm.value.email) { + ElMessage.warning('请填写完整信息') + return + } + + addingParticipant.value = true + try { + await addMeetingParticipant(meeting.value.id, participantForm.value) + ElMessage.success('添加成功') + showAddParticipantDialog.value = false + participantForm.value = { name: '', email: '' } + loadParticipants() + } catch (error: any) { + ElMessage.error(error?.response?.data?.detail || '添加失败') + } finally { + addingParticipant.value = false + } +} + +async function deleteParticipant(participantId: number) { + if (!meeting.value) return + + try { + await deleteMeetingParticipant(meeting.value.id, participantId) + ElMessage.success('删除成功') + loadParticipants() + } catch (error: any) { + ElMessage.error('删除失败') + } +} + +async function importFromCommittee() { + if (!meeting.value) return + + importing.value = true + try { + const result = await importParticipantsFromCommittee(meeting.value.id) + ElMessage.success(`导入成功!共导入 ${result.added_count} 人,跳过 ${result.skipped_count} 人`) + showImportDialog.value = false + loadParticipants() + } catch (error: any) { + ElMessage.error(error?.response?.data?.detail || '导入失败') + } finally { + importing.value = false + } +} + +async function sendCalendarInvite() { + if (!meeting.value || participants.value.length === 0) return + + sendingCalendar.value = true + try { + // Create an immediate reminder to send calendar invites + await createMeetingReminder(meeting.value.id, 'immediate') + ElMessage.success('日历邀请已发送!') + loadReminders() + } catch (error: any) { + ElMessage.error(error?.response?.data?.detail || '发送失败') + } finally { + sendingCalendar.value = false + } +} + function formatDateTime(dateStr: string) { return new Date(dateStr).toLocaleString('zh-CN', { year: 'numeric', @@ -484,4 +692,23 @@ function getReminderTypeText(type: string) { justify-content: flex-end; gap: 12px; } + +.header-actions { + display: flex; + gap: 8px; +} + +.send-calendar-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--el-border-color-lighter); + display: flex; + align-items: center; + gap: 12px; +} + +.hint-text { + color: #909399; + font-size: 12px; +} diff --git a/frontend/src/views/PublishView.vue b/frontend/src/views/PublishView.vue index f14866b..4ed99c6 100644 --- a/frontend/src/views/PublishView.vue +++ b/frontend/src/views/PublishView.vue @@ -58,25 +58,21 @@ > 复制内容 - div> + + + -
-
-

发布记录

-
{{ rec.status }} - div> + +
+ -
-
-

{{ activeChannel ? channelLabel(activeChannel) + ' 预览' : '渠道预览' }}

-