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..14ba8b75d 100644
--- a/backend/src/appointment/database/models.py
+++ b/backend/src/appointment/database/models.py
@@ -91,6 +91,9 @@ def touch(self):
"""Updates the time_updated field with the current datetime. This function does not save the model!"""
self.time_updated = datetime.datetime.now()
+ def get_columns(self) -> list:
+ return list(self.__table__.columns.keys())
+
time_created = Column(DateTime, server_default=func.now(), default=func.now(), index=True)
time_updated = Column(DateTime, server_default=func.now(), default=func.now(), onupdate=func.now(), index=True)
@@ -102,7 +105,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 +129,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..de9908a26 100644
--- a/backend/src/appointment/database/repo/subscriber.py
+++ b/backend/src/appointment/database/repo/subscriber.py
@@ -47,7 +47,13 @@ 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()
+
+ # Filter incoming data to just the available model columns
+ columns = models.Subscriber().get_columns()
+ data = {k: v for k, v in data.items() if k in columns}
+
+ 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/de.json b/frontend/src/locales/de.json
index 5a9d32f1e..7a13e709e 100644
--- a/frontend/src/locales/de.json
+++ b/frontend/src/locales/de.json
@@ -197,6 +197,7 @@
"password": "Passwort",
"past": "Vergangen",
"pending": "Ausstehend",
+ "preferredEmail": "Bevorzugte E-Mail-Adresse",
"primaryTimeZone": "Primäre Zeitzone",
"privacy": "Datenschutz",
"refresh": "Erneuern",
@@ -301,6 +302,7 @@
"continueToFxa": "Bitte die E-Mail-Adresse des Mozilla-Kontos eingeben."
},
"nameIsInvitingYou": "{name} lädt dich ein",
+ "preferredEmailHelp": "Die E-Mail-Adresse festlegen, die für ausgehende Kommunikation in Kalenderereignissen verwendet werden soll.",
"recipientsCanScheduleBetween": "Empfänger können einen Termin zwischen {earliest} und {farthest} ab dem aktuellen Zeitpunkt wählen. ",
"refreshLinkNotice": "Dadurch wird dein Link erneuert. Deine alten Links werden nicht länger funktionieren.",
"requestInformationSentToOwner": "Der Kalenderbesitzer wurde per E-Mail über deine Buchungsanfrage informiert.",
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,
};
});