diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 1d02a3d3c..436d3f082 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -396,8 +396,8 @@ def create_vevent( cal = Calendar() cal.add("prodid", "-//Thunderbird Appointment//tba.dk//") cal.add("version", "2.0") - org = vCalAddress("MAILTO:" + organizer.email) - org.params["cn"] = vText(organizer.name) + org = vCalAddress("MAILTO:" + organizer.preferred_email) + org.params["cn"] = vText(organizer.preferred_email) org.params["role"] = vText("CHAIR") event = Event() event.add("uid", appointment.uuid.hex) diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index c729038a9..a0f21a0fc 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -102,7 +102,11 @@ class Subscriber(Base): username = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True) # Encrypted (here) and hashed (by the associated hashing functions in routes/auth) password = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) + + # Use subscriber.preferred_email for any email, or other user-facing presence. email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True) + secondary_email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), nullable=True, index=True) + name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True) timezone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) @@ -122,6 +126,11 @@ def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConn """Retrieves the first found external connection by type or returns None if not found""" return next(filter(lambda ec: ec.type == type, self.external_connections), None) + @property + def preferred_email(self): + """Returns the preferred email address.""" + return self.secondary_email if self.secondary_email is not None else self.email + class Calendar(Base): __tablename__ = "calendars" diff --git a/backend/src/appointment/database/repo/subscriber.py b/backend/src/appointment/database/repo/subscriber.py index bc8d4a5e5..d3a0fface 100644 --- a/backend/src/appointment/database/repo/subscriber.py +++ b/backend/src/appointment/database/repo/subscriber.py @@ -47,7 +47,12 @@ def get_by_google_state(db: Session, state: str): def create(db: Session, subscriber: schemas.SubscriberBase): """create new subscriber""" - db_subscriber = models.Subscriber(**subscriber.dict()) + data = subscriber.model_dump() + + # Remove virtual fields + del data['preferred_email'] + + db_subscriber = models.Subscriber(**data) db.add(db_subscriber) db.commit() db.refresh(db_subscriber) diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 420950ede..5c2768f73 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -246,10 +246,12 @@ class SubscriberIn(BaseModel): username: str name: str | None = None avatar_url: str | None = None + secondary_email: str | None = None class SubscriberBase(SubscriberIn): email: str + preferred_email: str | None = None level: SubscriberLevel | None = SubscriberLevel.basic diff --git a/backend/src/appointment/migrations/versions/2024_05_28_1745-9fe08ba6f2ed_update_subscribers_add_.py b/backend/src/appointment/migrations/versions/2024_05_28_1745-9fe08ba6f2ed_update_subscribers_add_.py new file mode 100644 index 000000000..167f022e2 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_05_28_1745-9fe08ba6f2ed_update_subscribers_add_.py @@ -0,0 +1,31 @@ +"""update subscribers add preferred_email + +Revision ID: 9fe08ba6f2ed +Revises: 89e1197d980d +Create Date: 2024-05-28 17:45:48.192560 + +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine + + +def secret(): + return os.getenv("DB_SECRET") + + +# revision identifiers, used by Alembic. +revision = '9fe08ba6f2ed' +down_revision = '89e1197d980d' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('subscribers', sa.Column('secondary_email', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), nullable=True, index=True)) + + +def downgrade() -> None: + op.drop_column('subscribers', 'secondary_email') diff --git a/backend/src/appointment/routes/account.py b/backend/src/appointment/routes/account.py index b41630f87..0665ac946 100644 --- a/backend/src/appointment/routes/account.py +++ b/backend/src/appointment/routes/account.py @@ -9,7 +9,8 @@ from ..dependencies.auth import get_subscriber from ..dependencies.database import get_db -from ..database.models import Subscriber +from ..database.models import Subscriber, ExternalConnectionType +from ..database.repo.external_connection import get_by_type from ..database import schemas from fastapi.responses import StreamingResponse @@ -30,10 +31,11 @@ def get_external_connections(subscriber: Subscriber = Depends(get_subscriber)): for ec in subscriber.external_connections: external_connections[ec.type.name].append(schemas.ExternalConnectionOut(owner_id=ec.owner_id, type=ec.type.name, - type_id=ec.type_id, name=ec.name)) + type_id=ec.type_id, name=ec.name)) return external_connections + @router.get("/download") def download_data(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Download your account data in zip format! Returns a streaming response with the zip buffer.""" @@ -52,3 +54,16 @@ def delete_account(db: Session = Depends(get_db), subscriber: Subscriber = Depen return data.delete_account(db, subscriber) except AccountDeletionException as e: raise HTTPException(status_code=500, detail=e.message) + + +@router.get("/available-emails") +def get_available_emails(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): + """Return the list of emails they can use within Thunderbird Appointment""" + google_connections = get_by_type(db, subscriber_id=subscriber.id, type=ExternalConnectionType.google) + + emails = {subscriber.email, *[connection.name for connection in google_connections]} - {subscriber.preferred_email} + + return [ + subscriber.preferred_email, + *emails + ] diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 6a12c350d..a3ea801c3 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -52,7 +52,12 @@ def update_me( me = repo.subscriber.update(db=db, data=data, subscriber_id=subscriber.id) return schemas.SubscriberBase( - username=me.username, email=me.email, name=me.name, level=me.level, timezone=me.timezone + username=me.username, + email=me.email, + preferred_email=me.preferred_email, + name=me.name, + level=me.level, + timezone=me.timezone ) @@ -473,7 +478,7 @@ def send_feedback( background_tasks.add_task( send_support_email, requestee_name=subscriber.name, - requestee_email=subscriber.email, + requestee_email=subscriber.preferred_email, topic=form_data.topic, details=form_data.details, ) diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index e690e454d..3007c003b 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -258,8 +258,13 @@ def me( ): """Return the currently authed user model""" return schemas.SubscriberBase( - username=subscriber.username, email=subscriber.email, name=subscriber.name, level=subscriber.level, - timezone=subscriber.timezone, avatar_url=subscriber.avatar_url + username=subscriber.username, + email=subscriber.email, + preferred_email=subscriber.preferred_email, + name=subscriber.name, + level=subscriber.level, + timezone=subscriber.timezone, + avatar_url=subscriber.avatar_url ) diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index 49f8abe71..4b3fb7cf8 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -124,8 +124,15 @@ def disconnect_account( # Remove all of their google calendars (We only support one connection so this should be good for now) repo.calendar.delete_by_subscriber_and_provider(db, subscriber.id, provider=models.CalendarProvider.google) + # Unassociated any secondary emails if they're attached to their google connection + if subscriber.secondary_email == google_connection.name: + subscriber.secondary_email = None + db.add(subscriber) + db.commit() + # Remove their account details repo.external_connection.delete_by_type(db, subscriber.id, google_connection.type, google_connection.type_id) + return True diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 449dd0d02..4a71a0a5c 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -254,7 +254,7 @@ def request_schedule_availability_slot( db.commit() # Sending confirmation email to owner - background_tasks.add_task(send_confirmation_email, url=url, attendee_name=attendee.name, date=date, to=subscriber.email) + background_tasks.add_task(send_confirmation_email, url=url, attendee_name=attendee.name, date=date, to=subscriber.preferred_email) # Sending pending email to attendee background_tasks.add_task(send_pending_email, owner_name=subscriber.name, date=attendee_date, to=slot.attendee.email) @@ -376,7 +376,7 @@ def decide_on_schedule_availability_slot( capture_exception(err) # Notify the organizer that the meeting link could not be created! - background_tasks.add_task(send_zoom_meeting_failed_email, to=subscriber.email, appointment_title=schedule.name) + background_tasks.add_task(send_zoom_meeting_failed_email, to=subscriber.preferred_email, appointment_title=schedule.name) except SQLAlchemyError as err: # Not fatal, but could make things tricky logging.error("Failed to save the zoom meeting link to the appointment: ", err) if os.getenv('SENTRY_DSN') != '': diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index c4aecb57c..ef5c5bb13 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -6,6 +6,15 @@ class TestAuth: + def test_me(self, with_db, with_client): + response = with_client.get('/me', headers=auth_headers) + assert response.status_code == 200, response.text + data = response.json() + assert data.get('username') == os.getenv('TEST_USER_EMAIL') + assert data.get('email') == os.getenv('TEST_USER_EMAIL') + assert data.get('secondary_email') is None + assert data.get('preferred_email') == os.getenv('TEST_USER_EMAIL') + def test_token(self, with_db, with_client, make_pro_subscriber): """Test that our username/password authentication works correctly.""" password = 'test' diff --git a/backend/test/integration/test_profile.py b/backend/test/integration/test_profile.py index cd4dbdd08..4a848d508 100644 --- a/backend/test/integration/test_profile.py +++ b/backend/test/integration/test_profile.py @@ -12,6 +12,7 @@ def test_update_me(self, with_db, with_client): "username": "test", "name": "Test Account", "timezone": "Europe/Berlin", + "secondary_email": "useme@example.org" }, headers=auth_headers, ) @@ -20,14 +21,8 @@ def test_update_me(self, with_db, with_client): assert data["username"] == "test" assert data["name"] == "Test Account" assert data["timezone"] == "Europe/Berlin" - - # Can't test login right now - - # response = client.get("/login", headers=headers) - # data = response.json() - # assert data["username"] == "test" - # assert data["name"] == "Test Account" - # assert data["timezone"] == "Europe/Berlin" + # Response returns preferred_email + assert data["preferred_email"] == "useme@example.org" # Confirm the data was saved with with_db() as db: @@ -35,6 +30,8 @@ def test_update_me(self, with_db, with_client): assert subscriber.username == "test" assert subscriber.name == "Test Account" assert subscriber.timezone == "Europe/Berlin" + assert subscriber.secondary_email == "useme@example.org" + assert subscriber.preferred_email == "useme@example.org" def test_signed_short_link(self, with_client): """Retrieves our unique short link, and ensures it exists""" diff --git a/frontend/src/components/BookingModal.vue b/frontend/src/components/BookingModal.vue index a3121ffca..616a37e61 100644 --- a/frontend/src/components/BookingModal.vue +++ b/frontend/src/components/BookingModal.vue @@ -79,7 +79,9 @@ + diff --git a/frontend/src/components/SettingsConnections.vue b/frontend/src/components/SettingsConnections.vue index 5bfc0a00c..9c468fb65 100644 --- a/frontend/src/components/SettingsConnections.vue +++ b/frontend/src/components/SettingsConnections.vue @@ -98,6 +98,7 @@ const call = inject('call'); const router = useRouter(); const externalConnectionsStore = useExternalConnectionsStore(); const calendarStore = useCalendarStore(); +const userStore = useUserStore(); const { connections } = storeToRefs(externalConnectionsStore); const { $reset: resetConnections } = externalConnectionsStore; @@ -117,6 +118,8 @@ const refreshData = async () => { await Promise.all([ externalConnectionsStore.fetch(call), calendarStore.fetch(call), + // Need to update userStore in case they used an attached email + userStore.profile(call), ]); }; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index e9e3a1709..0f5ed2a07 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -197,6 +197,7 @@ "password": "Password", "past": "Past", "pending": "Pending", + "preferredEmail": "Preferred Email", "primaryTimeZone": "Primary time zone", "privacy": "Privacy", "refresh": "Refresh", @@ -301,6 +302,7 @@ "continueToFxa": "Enter your email above to continue to Mozilla Accounts" }, "nameIsInvitingYou": "{name} is inviting you", + "preferredEmailHelp": "Set the email you'll use for out-going communication. This will be used in calendar events and emails.", "recipientsCanScheduleBetween": "Recipients can schedule a {duration} appointment between {earliest} and {farthest} ahead of time.", "refreshLinkNotice": "This refreshes your link. Your old links will no longer work.", "requestInformationSentToOwner": "An information about this booking request has been emailed to the owner.", diff --git a/frontend/src/stores/user-store.js b/frontend/src/stores/user-store.js index 9718b0dd5..3be03b88b 100644 --- a/frontend/src/stores/user-store.js +++ b/frontend/src/stores/user-store.js @@ -4,6 +4,7 @@ import { i18n } from '@/composables/i18n'; const initialUserObject = { email: null, + preferredEmail: null, level: null, name: null, timezone: null, @@ -21,6 +22,21 @@ export const useUserStore = defineStore('user', () => { data.value = structuredClone(initialUserObject); }; + const updateProfile = (userData) => { + data.value = { + // Include the previous values first + ...data.value, + // Then the new ones! + username: userData.username, + name: userData.name, + email: userData.email, + preferredEmail: userData?.preferred_email ?? userData.email, + level: userData.level, + timezone: userData.timezone, + avatarUrl: userData.avatar_url, + }; + }; + /** * Retrieve the current signed url and update store * @param {function} fetch preconfigured API fetch function @@ -52,17 +68,7 @@ export const useUserStore = defineStore('user', () => { return { error: userData.value?.detail ?? error.value }; } - data.value = { - // Include the previous values first - ...data.value, - // Then the new ones! - username: userData.value.username, - name: userData.value.name, - email: userData.value.email, - level: userData.value.level, - timezone: userData.value.timezone, - avatarUrl: userData.value.avatar_url, - }; + updateProfile(userData.value); return updateSignedUrl(fetch); }; @@ -130,6 +136,6 @@ export const useUserStore = defineStore('user', () => { }; return { - data, exists, $reset, updateSignedUrl, profile, changeSignedUrl, login, logout, + data, exists, $reset, updateSignedUrl, profile, updateProfile, changeSignedUrl, login, logout, }; });