diff --git a/openbook/helpers.py b/openbook/helpers.py
deleted file mode 100644
index dc0cd3848ddf1c51c377a275acfc2766fde83837..0000000000000000000000000000000000000000
--- a/openbook/helpers.py
+++ /dev/null
@@ -1,111 +0,0 @@
-import structlog
-
-from openbook_auth.models import User
-from openbook_common.pubsub.publisher import publish_event
-from openbook_communities.events.events import (
-    SpaceCreatedEvent,
-    SpaceUpdatedEvent,
-    SpaceDeletedEvent,
-    SpaceUserAddedEvent,
-    SpaceUserLeftEvent,
-    SpaceUserRemovedEvent,
-    UserDeletedEvent,
-    UserNameUpdatedEvent,
-    UserEmailUpdatedEvent,
-)
-from openbook_communities.events.payloads import SpacePayload, UserPayload
-from openbook_communities.events.topic import Topic as PubSubTopic
-from openbook_communities.models import Community
-
-logger = structlog.getLogger(__name__)
-
-
-def publish_space_created_event(community: Community):
-    try:
-        creator = UserPayload.from_user(community.creator)
-        space = SpacePayload.from_community(community)
-        event = SpaceCreatedEvent(creator, space)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(SpaceCreatedEvent), str(e))
-
-
-def publish_space_updated_event(community: Community):
-    try:
-        creator = UserPayload.from_user(community.creator)
-        space = SpacePayload.from_community(community)
-        event = SpaceUpdatedEvent(creator, space)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(SpaceUpdatedEvent), str(e))
-
-
-def publish_space_user_added_event(username: str, community: Community):
-    try:
-        space_member: User = User.objects.get(username=username)
-        user = UserPayload.from_user(space_member)
-        space = SpacePayload.from_community(community)
-        event = SpaceUserAddedEvent(user, space)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(SpaceUserAddedEvent), str(e))
-
-
-def publish_user_name_updated_event(user: User):
-    try:
-        updated_user = UserPayload.from_user(user)
-        event = UserNameUpdatedEvent(updated_user)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(UserNameUpdatedEvent), str(e))
-
-
-def publish_user_email_updated_event(user: User):
-    try:
-        updated_user = UserPayload.from_user(user)
-        event = UserEmailUpdatedEvent(updated_user)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(UserEmailUpdatedEvent), str(e))
-
-
-def publish_space_deleted_event(community: Community):
-    try:
-        space = SpacePayload.from_community(community)
-        event = SpaceDeletedEvent(space)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(SpaceDeletedEvent), str(e))
-
-
-def publish_user_deleted_event(user: User):
-    try:
-        deleted_user = UserPayload.from_user(user)
-        event = UserDeletedEvent(deleted_user)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(UserDeletedEvent), str(e))
-
-
-def publish_space_user_left_event(user: User, community: Community):
-    try:
-        user = UserPayload.from_user(user)
-        space = SpacePayload.from_community(community)
-        event = SpaceUserLeftEvent(user, space)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(SpaceUserLeftEvent), str(e))
-
-
-def publish_space_user_removed_event(user: User, community: Community):
-    try:
-        user = UserPayload.from_user(user)
-        space = SpacePayload.from_community(community)
-        event = SpaceUserRemovedEvent(user, space)
-        publish_event(event=event, topic=PubSubTopic.OKUNA.value)
-    except Exception as e:
-        logger.error(__generate_publish_failure_message(SpaceUserRemovedEvent), str(e))
-
-
-def __generate_publish_failure_message(cls):
-    return f"Failed to publish {cls.__name__} event: %s"
diff --git a/openbook/settings/__init__.py b/openbook/settings/__init__.py
index c74cddd24e3504bd168c66646a26008ddb93799d..bd847c47fab6977619802824e0121100e5eef786 100644
--- a/openbook/settings/__init__.py
+++ b/openbook/settings/__init__.py
@@ -491,6 +491,7 @@ CONTENT_IMAGE_ALT_TEXT_MAX_LENGTH = 128
 TITLE_MAX_LENGTH = 128
 USERNAME_MAX_LENGTH = 36
 IDENTITY_MAX_LENGTH = 255
+USER_EMAIL_MAX_LENGTH = 254
 IMAGE_SIZE_10_MB = 10485760
 IMAGE_SIZE_08_MB = 8388608
 FILE_UPLOAD_MAX_MEMORY_SIZE = IMAGE_SIZE_10_MB
@@ -519,9 +520,9 @@ PROFILE_AVATAR_URL_MAX_LENGTH = 500
 PROFILE_COVER_MAX_SIZE = int(os.environ.get("PROFILE_COVER_MAX_SIZE", "10485760"))
 PROFILE_COVER_URL_MAX_LENGTH = 500
 PASSWORD_RESET_TIMEOUT_DAYS = 1
-COMMUNITY_NAME_MAX_LENGTH = 60
+COMMUNITY_NAME_MAX_LENGTH = 80
 COMMUNITY_TITLE_MIN_LENGTH = 2
-COMMUNITY_TITLE_MAX_LENGTH = 60
+COMMUNITY_TITLE_MAX_LENGTH = 80
 COMMUNITY_LOCATION_MAX_LENGTH = 192
 COMMUNITY_DESCRIPTION_MAX_LENGTH = 600
 COMMUNITY_USER_ADJECTIVE_MAX_LENGTH = 16
diff --git a/openbook/tests/test_events.py b/openbook/tests/test_events.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/openbook_auth/admin.py b/openbook_auth/admin.py
index 9eedb4f71ea33100dad5796e67dbd8492a9c91e3..5d18083d3e98c5de0811db84c5328677f5b7861f 100644
--- a/openbook_auth/admin.py
+++ b/openbook_auth/admin.py
@@ -3,7 +3,7 @@ import traceback
 from django.contrib import admin
 from rangefilter.filters import DateRangeQuickSelectListFilterBuilder
 
-from openbook.helpers import publish_user_name_updated_event
+from openbook_auth.events import publish_user_name_updated_event
 from openbook_auth.forms import UserProfileFormSet
 from openbook_auth.models import User, UserProfile
 import structlog
diff --git a/openbook_auth/events.py b/openbook_auth/events.py
new file mode 100644
index 0000000000000000000000000000000000000000..67e2ff71389656f124bb29870f9712371255de0a
--- /dev/null
+++ b/openbook_auth/events.py
@@ -0,0 +1,129 @@
+from enum import Enum
+from typing import Optional
+
+import structlog
+from openbook_auth.models import User
+from openbook_common.pubsub.event import Event
+from openbook_common.pubsub.topic import Topic
+from openbook_common.pubsub.publisher import publish_event
+
+logger = structlog.getLogger(__name__)
+
+
+class UserPayload:
+    def __init__(
+        self,
+        id: str,
+        name: str,
+        email: str,
+        identity: str,
+        avatar: Optional[str] = None,
+        about_me: Optional[str] = None,
+        location: Optional[str] = None,
+    ):
+        self.id = id
+        self.name = name
+        self.email = email
+        self.identity = identity
+        self.avatar = avatar
+        self.about_me = about_me
+        self.location = location
+
+    def to_dict(self):
+        return {
+            "id": self.id,
+            "name": self.name,
+            "email": self.email,
+            "identity": self.identity,
+            "avatar": self.avatar,
+            "aboutMe": self.about_me,
+            "location": self.location,
+        }
+
+    @classmethod
+    def from_user(cls, user: User):
+        avatar_url = user.profile.avatar.url if user.profile.avatar else None
+        return cls(
+            id=str(user.username),
+            name=user.profile.full_name(),
+            email=user.email,
+            identity=user.identity,
+            avatar=avatar_url,
+            about_me=user.profile.about_me,
+            location=user.profile.location,
+        )
+
+
+class UserEventType(Enum):
+    USER_NAME_UPDATED = "UserNameUpdated"
+    USER_EMAIL_UPDATED = "UserEmailUpdated"
+    USER_UPDATED = "UserUpdated"
+    USER_DELETED = "UserDeleted"
+
+
+class UserUpdatedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, user: UserPayload):
+        super().__init__(
+            data={"user": user.to_dict()},
+            event_type=UserEventType.USER_UPDATED.value,
+            event_version=UserUpdatedEvent.EVENT_VERSION,
+        )
+
+
+class UserNameUpdatedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, user: UserPayload):
+        super().__init__(
+            data={"user": user.to_dict()},
+            event_type=UserEventType.USER_NAME_UPDATED.value,
+            event_version=UserNameUpdatedEvent.EVENT_VERSION,
+        )
+
+
+class UserEmailUpdatedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, user: UserPayload):
+        super().__init__(
+            data={"user": user.to_dict()},
+            event_type=UserEventType.USER_EMAIL_UPDATED.value,
+            event_version=UserEmailUpdatedEvent.EVENT_VERSION,
+        )
+
+
+class UserDeletedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, user: UserPayload):
+        super().__init__(
+            data={"user": user.to_dict()},
+            event_type=UserEventType.USER_DELETED.value,
+            event_version=UserDeletedEvent.EVENT_VERSION,
+        )
+
+
+def publish_user_deleted_event(user: User):
+    deleted_user = UserPayload.from_user(user)
+    event = UserDeletedEvent(deleted_user)
+    publish_event(event=event, topic=Topic.OKUNA.value)
+
+
+def publish_user_name_updated_event(user: User):
+    updated_user = UserPayload.from_user(user)
+    event = UserNameUpdatedEvent(updated_user)
+    publish_event(event=event, topic=Topic.OKUNA.value)
+
+
+def publish_user_updated_event(user: User):
+    updated_user = UserPayload.from_user(user)
+    event = UserUpdatedEvent(updated_user)
+    publish_event(event=event, topic=Topic.OKUNA.value)
+
+
+def publish_user_email_updated_event(user: User):
+    updated_user = UserPayload.from_user(user)
+    event = UserEmailUpdatedEvent(updated_user)
+    publish_event(event=event, topic=Topic.OKUNA.value)
diff --git a/openbook_auth/migrations/0033_alter_user_email.py b/openbook_auth/migrations/0033_alter_user_email.py
new file mode 100644
index 0000000000000000000000000000000000000000..a43b6a0aaf3bc63d0fbb1f7db74d27df615e2de9
--- /dev/null
+++ b/openbook_auth/migrations/0033_alter_user_email.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.8 on 2024-12-13 10:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("openbook_auth", "0032_alter_user_role"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="user",
+            name="email",
+            field=models.CharField(max_length=254, unique=True, verbose_name="email address"),
+        ),
+    ]
diff --git a/openbook_auth/models.py b/openbook_auth/models.py
index 54c9227efd5f3206e6e96eee1464d90cc0ea1090..38584889cd3d2f379c20bd5be89f490a1a9c9eda 100644
--- a/openbook_auth/models.py
+++ b/openbook_auth/models.py
@@ -196,7 +196,14 @@ class User(ModelWithUUID, AbstractUser):
         related_name="translation_users",
     )
 
-    email = models.EmailField(_("email address"), unique=True, null=False, blank=False)
+    email = models.CharField(
+        _("email address"),
+        max_length=settings.USER_EMAIL_MAX_LENGTH,
+        unique=True,
+        null=False,
+        blank=False,
+    )
+
     connections_circle = models.ForeignKey(
         "openbook_circles.Circle",
         on_delete=models.CASCADE,
diff --git a/openbook_auth/schema/mutations.py b/openbook_auth/schema/mutations.py
index f15c0de13b24c6c39722e6de9ffaca815cf66b8b..024c217eb5cde308ae475da17f32aa1d5c30b056 100644
--- a/openbook_auth/schema/mutations.py
+++ b/openbook_auth/schema/mutations.py
@@ -1,6 +1,7 @@
 # ruff: noqa
 import uuid
 from copy import deepcopy
+from datetime import datetime, timedelta
 
 import requests
 import strawberry
@@ -8,15 +9,17 @@ import structlog
 from asgiref.sync import sync_to_async
 from django.conf import settings
 from django.core.cache import cache
+from django.db.models import When, Value, Count, Case, IntegerField
 from django.utils.translation import get_language_from_request
 from graphql import GraphQLError
 from rest_framework.exceptions import ValidationError
 
 from openbook_common.tracking import track, TrackingEvent
-from openbook.helpers import (
-    publish_space_deleted_event,
+from openbook_communities.events import publish_space_deleted_event
+from openbook_auth.events import (
     publish_user_deleted_event,
     publish_user_name_updated_event,
+    publish_user_updated_event,
 )
 from openbook.utils import invalidate_discover_spaces_query_cache
 from openbook_auth.models import User as UserModel
@@ -30,9 +33,12 @@ from openbook_auth.utils import verify_authorized_user
 from openbook.graphql_errors import GraphQLErrorCodes
 from openbook_common.helpers import is_parameter_null, to_input_dict
 from openbook_common.schema.types import Result
+from openbook_common.utils.model_loaders import get_insight_model, get_community_model, get_topic_model
+from openbook_communities.schema.helpers import create_filtered_spaces_query
 from openbook_notifications.api import NotificationApi
 from openbook_notifications.enums import NotificationProvider
 from openbook_devices.serializers import RegisterDeviceSerializer
+from openbook_notifications.services import NotificationsService
 
 logger = structlog.get_logger(__name__)
 
@@ -49,6 +55,9 @@ class Mutation:
     def update_authenticated_user_v2(self, info, input: UpdateAuthenticatedUserInput) -> AuthenticatedUser:
         current_user = verify_authorized_user(info)
 
+        user_entity = UserModel.objects.get(username=current_user.username)
+        user_current_name = user_entity.profile.name
+
         input_dict = to_input_dict(input)
         if input.first_name:
             input_dict["name"] = input.first_name
@@ -88,9 +97,57 @@ class Mutation:
             update_ory_user_names(current_user.id)
             publish_user_name_updated_event(user=current_user)
 
+        publish_user_updated_event(user=current_user)
+
         locale = get_language_from_request(info.context.request)
         NotificationApi.create_or_update_subscriber(user=current_user, locale=locale)
 
+        user_updated_name = current_user.profile.name
+
+        # send welcome email only for newly created users
+        # include recommended spaces and latest insights in the welcome email
+        if user_current_name is None and user_updated_name is not None:
+            community_model = get_community_model()
+            insight_model = get_insight_model()
+            topic_model = get_topic_model()
+
+            whens = []
+            topic_ids = list(current_user.profile.interests.values_list("id", flat=True).all())
+            geolocation = current_user.geolocation
+
+            if topic_ids:
+                whens.append(
+                    When(create_filtered_spaces_query(topic_ids=topic_ids, geolocation=geolocation), then=Value(10))
+                )
+
+            if geolocation:
+                whens.append(When(create_filtered_spaces_query(geolocation=geolocation), then=Value(20)))
+
+            default_topics = list(
+                topic_model.objects.filter(is_discover_spaces_default=True).values_list("id", flat=True)
+            )
+            if default_topics:
+                whens.append(When(create_filtered_spaces_query(topic_ids=default_topics), then=Value(30)))
+
+            spaces = (
+                community_model.objects.prefetch_related("links", "memberships__user__profile")
+                .annotate(member_count=Count("memberships"))
+                .annotate(
+                    custom_order=Case(
+                        *whens,
+                        output_field=IntegerField(),
+                    )
+                )
+                .filter(custom_order__isnull=False, is_deleted=False)
+                .distinct()
+                .order_by("custom_order", "-created")[:4]
+            )
+
+            insights = insight_model.objects.order_by("-date_published")[:3]
+            NotificationsService.send_welcome_email_series_notification(
+                user=current_user, spaces=spaces, insights=insights
+            )
+
         track(current_user, TrackingEvent("userUpdated"))
 
         cache.set(f"{settings.USER_CACHE_PREFIX}{current_user.username}", current_user, timeout=300)
diff --git a/openbook_auth/tests/test_events.py b/openbook_auth/tests/test_events.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d7470d850d31247a5c2b5dfa3958663abc8131d
--- /dev/null
+++ b/openbook_auth/tests/test_events.py
@@ -0,0 +1,72 @@
+import json
+from typing import Any
+
+import pytest
+
+from openbook_auth.events import UserNameUpdatedEvent, UserPayload, UserDeletedEvent, UserEmailUpdatedEvent
+from openbook_auth.models import User
+from openbook_common.tests.helpers import make_user
+
+
+# noinspection DuplicatedCode
+def expected_user_payload(user: User) -> dict[str, Any]:
+    return {
+        "id": str(user.username),
+        "name": user.profile.full_name(),
+        "email": user.email,
+        "identity": user.identity,
+        "avatar": user.profile.avatar.url if user.profile.avatar else None,
+        "aboutMe": user.profile.about_me,
+        "location": user.profile.location,
+    }
+
+
+@pytest.mark.django_db
+def test_serialization_user_name_updated_event():
+    # GIVEN
+    user = make_user()
+
+    # WHEN
+    event = UserNameUpdatedEvent(UserPayload.from_user(user))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "UserNameUpdated",
+        "eventVersion": "1.1.0",
+        "data": (json.dumps({"user": expected_user_payload(user)}).encode("utf-8")),
+    }
+    assert event.to_dict() == expected_event_dict
+
+
+@pytest.mark.django_db
+def test_serialization_user_email_updated_event():
+    # GIVEN
+    user = make_user()
+
+    # WHEN
+    event = UserEmailUpdatedEvent(UserPayload.from_user(user))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "UserEmailUpdated",
+        "eventVersion": "1.1.0",
+        "data": (json.dumps({"user": expected_user_payload(user)}).encode("utf-8")),
+    }
+    assert event.to_dict() == expected_event_dict
+
+
+@pytest.mark.django_db
+def test_serialization_user_deleted_event():
+    # GIVEN
+    user = make_user()
+
+    # WHEN
+    event = UserDeletedEvent(UserPayload.from_user(user))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "UserDeleted",
+        "eventVersion": "1.1.0",
+        "data": (json.dumps({"user": expected_user_payload(user)}).encode("utf-8")),
+    }
+    assert event.to_dict() == expected_event_dict
diff --git a/openbook_auth/tests/test_graphql.py b/openbook_auth/tests/test_graphql.py
index 59405613e367330d1e1f998058095acc12909f11..4b3b40c9a524c72809d84e7e3fd164d50bac297f 100644
--- a/openbook_auth/tests/test_graphql.py
+++ b/openbook_auth/tests/test_graphql.py
@@ -3,6 +3,7 @@
 import json
 import logging
 import sys
+import uuid
 from datetime import datetime, timedelta
 from unittest import mock
 from unittest.mock import call
@@ -138,6 +139,84 @@ class TestUsers:
         assert authenticated_user["isEmployee"] == user.is_employee
         assert authenticated_user["connectionStatusToMyself"] is None
 
+    @pytest.mark.asyncio
+    @mock.patch("django.utils.translation.get_language_from_request", return_value="en")
+    @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber")
+    async def test_creates_user_if_not_exists(
+        self, mock_create_or_update_subscriber, mock_get_language_from_request, client
+    ):
+        username = uuid.uuid4()
+        email = "test@example.com"
+        ory_user = benedict({"username": username, "email": email})
+        headers = await sync_to_async(make_authentication_headers_for_user)(ory_user)
+        endpoint = reverse("graphql-endpoint")
+        graphql_query = """
+            query {
+                authenticatedUserV2 {
+                    id
+                }
+            }
+        """
+
+        # Ensure user does not exist yet
+        result = await sync_to_async(User.objects.filter)(username=username)
+        assert await sync_to_async(result.exists)() is False
+
+        response = await client.post(
+            path=endpoint,
+            data=json.dumps({"query": graphql_query}),
+            content_type="application/json",
+            **headers,
+        )
+
+        assert response.status_code == status.HTTP_200_OK
+
+        authenticated_user = json.loads(response.content)["data"]["authenticatedUserV2"]
+        assert authenticated_user["id"] == str(username)
+
+        # Ensure user was created
+        user = await sync_to_async(User.objects.get)(username=username)
+        assert user.email == email
+
+    @pytest.mark.asyncio
+    @mock.patch("django.utils.translation.get_language_from_request", return_value="en")
+    @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber")
+    async def test_creates_user_with_invalid_email(
+        self, mock_create_or_update_subscriber, mock_get_language_from_request, client
+    ):
+        username = uuid.uuid4()
+        email = "invalid address"
+        ory_user = benedict({"username": username, "email": email})
+        headers = await sync_to_async(make_authentication_headers_for_user)(ory_user)
+        endpoint = reverse("graphql-endpoint")
+        graphql_query = """
+            query {
+                authenticatedUserV2 {
+                    id
+                }
+            }
+        """
+
+        # Ensure user does not exist yet
+        result = await sync_to_async(User.objects.filter)(username=username)
+        assert await sync_to_async(result.exists)() is False
+
+        response = await client.post(
+            path=endpoint,
+            data=json.dumps({"query": graphql_query}),
+            content_type="application/json",
+            **headers,
+        )
+
+        assert response.status_code == status.HTTP_200_OK
+
+        authenticated_user = json.loads(response.content)["data"]["authenticatedUserV2"]
+        assert authenticated_user["id"] == str(username)
+
+        # Ensure user was created
+        user = await sync_to_async(User.objects.get)(username=username)
+        assert user.email == email
+
     def test_get_user_by_name_returns_only_matching_users_without_logged_in_user(self):
         user = make_user(name="Peter", last_name="Moser")
         user2 = make_user(name="Peter", last_name="Muster")
@@ -248,6 +327,7 @@ class TestUsers:
     # TODO HOLI-8088 remove test once update_authenticated_user is removed
     @mock.patch("openbook_auth.tasks.requests.patch")
     @mock.patch("openbook_auth.schema.mutations.publish_user_name_updated_event")
+    @mock.patch("openbook_auth.schema.mutations.publish_user_updated_event")
     @mock.patch("django.utils.translation.get_language_from_request", return_value="en")
     @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber")
     @mock.patch("openbook_auth.schema.mutations.track")
@@ -257,10 +337,11 @@ class TestUsers:
         mock_create_or_update_subscriber,
         mock_get_language_from_request,
         mock_publish_user_name_updated_event,
-        mock_patch,
+        mock_publish_user_updated_event,
+        mock_ory_patch,
     ):
-        response = self.create_response(200)
-        mock_patch.return_value = response
+        mock_ory_patch.return_value = self.create_response(200)
+
         user = make_user(name="Fritz", last_name="Meier", identity="fritz")
         community = make_community()
         community.add_member(user)
@@ -272,12 +353,6 @@ class TestUsers:
         last_name = "Shaw"
         engagement_level = EngagementLevelChoice.INITIATOR
 
-        json_body = [
-            {"op": "add", "path": "/traits/given_name", "value": f"{first_name}"},
-            {"op": "add", "path": "/traits/family_name", "value": f"{last_name}"},
-            {"op": "add", "path": "/traits/name", "value": f"{first_name} {last_name}"},
-        ]
-
         request = HttpRequest()
         request.COOKIES = {}
         request.user = user
@@ -309,7 +384,12 @@ class TestUsers:
 
         assert executed.errors is None
 
-        mock_patch.assert_called_once_with(
+        json_body = [
+            {"op": "add", "path": "/traits/given_name", "value": f"{first_name}"},
+            {"op": "add", "path": "/traits/family_name", "value": f"{last_name}"},
+            {"op": "add", "path": "/traits/name", "value": f"{first_name} {last_name}"},
+        ]
+        mock_ory_patch.assert_called_once_with(
             f"{settings.ORY_BASE_URL}/admin/identities/{user.username}",
             headers={"Authorization": f"Bearer {settings.ORY_ACCESS_TOKEN}"},
             json=json_body,
@@ -330,6 +410,7 @@ class TestUsers:
         assert geos.GEOSGeometry(json.dumps(geolocation_geometry)) == persisted_profile.geolocation
 
         mock_publish_user_name_updated_event.assert_called_once_with(user=user)
+        mock_publish_user_updated_event.assert_called_once_with(user=user)
         mock_create_or_update_subscriber.assert_called_once_with(
             user=user, locale=mock_get_language_from_request.return_value
         )
@@ -337,6 +418,7 @@ class TestUsers:
 
     @mock.patch("openbook_auth.tasks.requests.patch")
     @mock.patch("openbook_auth.schema.mutations.publish_user_name_updated_event")
+    @mock.patch("openbook_auth.schema.mutations.publish_user_updated_event")
     @mock.patch("django.utils.translation.get_language_from_request", return_value="en")
     @mock.patch("openbook_notifications.api.NotificationApi.create_or_update_subscriber")
     @mock.patch("openbook_auth.schema.mutations.track")
@@ -346,6 +428,7 @@ class TestUsers:
         mock_create_or_update_subscriber,
         mock_get_language_from_request,
         mock_publish_user_name_updated_event,
+        mock_publish_user_updated_event,
         mock_patch,
     ):
         response = self.create_response(200)
@@ -428,6 +511,7 @@ class TestUsers:
         assert geos.GEOSGeometry(json.dumps(geolocation_geometry)) == persisted_user.profile.geolocation
 
         mock_publish_user_name_updated_event.assert_called_once_with(user=user)
+        mock_publish_user_updated_event.assert_called_once_with(user=user)
         mock_create_or_update_subscriber.assert_called_once_with(
             user=user, locale=mock_get_language_from_request.return_value
         )
diff --git a/openbook_common/management/commands/generate_dummy_data.py b/openbook_common/management/commands/generate_dummy_data.py
index feb26b4e19f24ebea2b77da779caed186a4ec8f3..8e72bcfa92171418a2001a50251efa1cef70936e 100644
--- a/openbook_common/management/commands/generate_dummy_data.py
+++ b/openbook_common/management/commands/generate_dummy_data.py
@@ -15,7 +15,7 @@ from faker import Faker
 from feed_posts.enums import VisibilityLevel
 from feed_posts.models import FeedPost
 from openbook import settings
-from openbook.helpers import publish_user_name_updated_event
+from openbook_auth.events import publish_user_name_updated_event
 from openbook_auth.models import User
 from openbook_auth.tasks import generate_identity_when_needed
 from openbook_common.tests.helpers import (
diff --git a/openbook_common/pubsub/__init__.py b/openbook_common/pubsub/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/openbook_common/pubsub/publisher.py b/openbook_common/pubsub/publisher.py
index 16e96e99f37503176040e9e8fd6710023a252217..295a6b19ba3a1d296cc6a9d153520799a8573c86 100644
--- a/openbook_common/pubsub/publisher.py
+++ b/openbook_common/pubsub/publisher.py
@@ -3,6 +3,8 @@ from google.auth.credentials import AnonymousCredentials
 from openbook import settings
 import structlog
 
+from google.cloud.pubsub_v1.types import PublisherOptions
+
 from openbook_common.pubsub.event import Event
 from openbook_common.pubsub.wrappers import skip_test_events
 
@@ -10,21 +12,38 @@ logger = structlog.getLogger(__name__)
 
 GOOGLE_CLOUD_PROJECT_ID = settings.GOOGLE_CLOUD_PROJECT_ID
 ENVIRONMENT_ID = settings.ENVIRONMENT_ID
+PUBLISH_TIMEOUT_SECONDS = 15
+
+# subscribers can choose to consume messages in order. using a single ordering key because it gives the highest
+# guarantees, and we currently don't have scale issues that would justify relaxing guarantees.
+ORDERING_KEY = "okuna"
 
 
 @skip_test_events
 def publish_event(topic: str, event: Event) -> None:
+    """
+    Publishes an event to Google Cloud Pub/Sub. Guaranteed to not throw.
+    Awaits the result of the publish operation and logs any errors.
+    """
     try:
+        publisher_options = PublisherOptions(enable_message_ordering=True)
         publisher = (
-            pubsub_v1.PublisherClient(credentials=AnonymousCredentials())
+            pubsub_v1.PublisherClient(credentials=AnonymousCredentials(), publisher_options=publisher_options)
             if ENVIRONMENT_ID == "local"
-            else pubsub_v1.PublisherClient()
+            else pubsub_v1.PublisherClient(publisher_options=publisher_options)
         )
         topic_path = publisher.topic_path(GOOGLE_CLOUD_PROJECT_ID, topic)
-        publisher.publish(topic_path, **event.to_dict())
-        logger.info(f"Event published {event.to_dict()}")
-    except Exception as e:
-        logger.error(
-            f"Failed to publish event to topic {topic}. Make sure that the Google Cloud Pub/Sub API is enabled for your project and that the GOOGLE_APPLICATION_CREDENTIALS environment variable is set correctly.",
-            str(e),
-        )
+        future = publisher.publish(topic_path, ordering_key=ORDERING_KEY, **event.to_dict())
+
+        try:
+            future.result(PUBLISH_TIMEOUT_SECONDS)
+        except Exception as publish_error:
+            logger.error(_publish_error_msg(event, publish_error))
+
+    except Exception as setup_error:
+        # Handle general errors in the publishing setup or process
+        logger.error(_publish_error_msg(event, setup_error))
+
+
+def _publish_error_msg(event: Event, e: Exception) -> str:
+    return f"Failed to publish {type(event).__name__} event. Ensure Google Cloud Pub/Sub is enabled and GOOGLE_APPLICATION_CREDENTIALS is set correctly. Exception: {str(e)}"
diff --git a/openbook_communities/events/topic.py b/openbook_common/pubsub/topic.py
similarity index 100%
rename from openbook_communities/events/topic.py
rename to openbook_common/pubsub/topic.py
diff --git a/openbook_communities/admin.py b/openbook_communities/admin.py
index 35e4c49005c684a6c21872bb39cb5a16fe442ea0..d7f20295c7950ca72c1f4ae0db3f54aad84caf52 100644
--- a/openbook_communities/admin.py
+++ b/openbook_communities/admin.py
@@ -1,6 +1,10 @@
 from django.contrib import admin
 
-from openbook.helpers import publish_space_created_event, publish_space_user_added_event, publish_space_updated_event
+from openbook_communities.events import (
+    publish_space_created_event,
+    publish_space_updated_event,
+    publish_space_user_added_event,
+)
 from openbook_communities.models import (
     CollaborationTool,
     Community,
diff --git a/openbook_communities/events.py b/openbook_communities/events.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcb34e8218c257c9b97939dffb063432b02c23ab
--- /dev/null
+++ b/openbook_communities/events.py
@@ -0,0 +1,187 @@
+from enum import Enum
+from typing import Optional
+
+import structlog
+from django.contrib.gis.geos.point import Point
+
+from openbook_auth.events import UserPayload
+from openbook_auth.models import User
+from openbook_common.pubsub.event import Event
+from openbook_common.pubsub.publisher import publish_event
+from openbook_common.pubsub.topic import Topic as PubSubTopic
+from openbook_communities.models import Community
+
+logger = structlog.getLogger(__name__)
+
+
+class SpacePayload:
+    def __init__(
+        self,
+        id: str,
+        name: str,
+        slug: str,
+        avatar: str,
+        description: Optional[str] = None,
+        location: Optional[str] = None,
+        location_lat_lng: Optional[Point] = None,
+    ):
+        self.id = id
+        self.name = name
+        self.avatar = avatar
+        self.slug = slug
+        self.description = description
+        self.location = location
+        self.location_lat_lng = location_lat_lng
+
+    def to_dict(self):
+        return {
+            "id": self.id,
+            "name": self.name,
+            "slug": self.slug,
+            "avatar": self.avatar,
+            "description": self.description,
+            "location": self.location,
+            "locationLatLng": (
+                {
+                    "latitude": self.location_lat_lng.x,
+                    "longitude": self.location_lat_lng.y,
+                }
+                if self.location_lat_lng
+                else None
+            ),
+        }
+
+    @classmethod
+    def from_community(cls, community: Community):
+        if community.avatar:
+            avatarUrl = community.avatar.url
+        else:
+            avatarUrl = None
+
+        return cls(
+            id=str(community.id),
+            name=community.title,
+            slug=community.name,
+            avatar=avatarUrl,
+            description=community.description,
+            location=community.location,
+            location_lat_lng=community.geolocation,
+        )
+
+
+class SpaceUserRemovedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, user: UserPayload, space: SpacePayload):
+        super().__init__(
+            data={"user": user.to_dict(), "space": space.to_dict()},
+            event_type=SpaceEventType.SPACE_USER_REMOVED.value,
+            event_version=SpaceUserRemovedEvent.EVENT_VERSION,
+        )
+
+
+class SpaceUserLeftEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, user: UserPayload, space: SpacePayload):
+        super().__init__(
+            data={"user": user.to_dict(), "space": space.to_dict()},
+            event_type=SpaceEventType.SPACE_USER_LEFT.value,
+            event_version=SpaceUserLeftEvent.EVENT_VERSION,
+        )
+
+
+class SpaceDeletedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, space: SpacePayload):
+        super().__init__(
+            data={"space": space.to_dict()},
+            event_type=SpaceEventType.SPACE_DELETED.value,
+            event_version=SpaceDeletedEvent.EVENT_VERSION,
+        )
+
+
+class SpaceUserAddedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, user: UserPayload, space: SpacePayload):
+        super().__init__(
+            data={"user": user.to_dict(), "space": space.to_dict()},
+            event_type=SpaceEventType.SPACE_USER_ADDED.value,
+            event_version=SpaceUserAddedEvent.EVENT_VERSION,
+        )
+
+
+class SpaceUpdatedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, creator: UserPayload, space: SpacePayload):
+        super().__init__(
+            data={"creator": creator.to_dict(), "space": space.to_dict()},
+            event_type=SpaceEventType.SPACE_UPDATED.value,
+            event_version=SpaceUpdatedEvent.EVENT_VERSION,
+        )
+
+
+class SpaceCreatedEvent(Event):
+    EVENT_VERSION = "1.1.0"
+
+    def __init__(self, creator: UserPayload, space: SpacePayload):
+        super().__init__(
+            data={"creator": creator.to_dict(), "space": space.to_dict()},
+            event_type=SpaceEventType.SPACE_CREATED.value,
+            event_version=SpaceCreatedEvent.EVENT_VERSION,
+        )
+
+
+class SpaceEventType(Enum):
+    SPACE_CREATED = "SpaceCreated"
+    SPACE_UPDATED = "SpaceUpdated"
+    SPACE_DELETED = "SpaceDeleted"
+
+    SPACE_USER_ADDED = "SpaceUserAdded"
+    SPACE_USER_LEFT = "SpaceUserLeft"
+    SPACE_USER_REMOVED = "SpaceUserRemoved"
+
+
+def publish_space_created_event(community: Community):
+    creator = UserPayload.from_user(community.creator)
+    space = SpacePayload.from_community(community)
+    event = SpaceCreatedEvent(creator, space)
+    publish_event(event=event, topic=PubSubTopic.OKUNA.value)
+
+
+def publish_space_updated_event(community: Community):
+    creator = UserPayload.from_user(community.creator)
+    space = SpacePayload.from_community(community)
+    event = SpaceUpdatedEvent(creator, space)
+    publish_event(event=event, topic=PubSubTopic.OKUNA.value)
+
+
+def publish_space_user_added_event(username: str, community: Community):
+    space_member: User = User.objects.get(username=username)
+    user = UserPayload.from_user(space_member)
+    space = SpacePayload.from_community(community)
+    event = SpaceUserAddedEvent(user, space)
+    publish_event(event=event, topic=PubSubTopic.OKUNA.value)
+
+
+def publish_space_deleted_event(community: Community):
+    space = SpacePayload.from_community(community)
+    event = SpaceDeletedEvent(space)
+    publish_event(event=event, topic=PubSubTopic.OKUNA.value)
+
+
+def publish_space_user_left_event(user: User, community: Community):
+    user = UserPayload.from_user(user)
+    space = SpacePayload.from_community(community)
+    event = SpaceUserLeftEvent(user, space)
+    publish_event(event=event, topic=PubSubTopic.OKUNA.value)
+
+
+def publish_space_user_removed_event(user: User, community: Community):
+    user = UserPayload.from_user(user)
+    space = SpacePayload.from_community(community)
+    event = SpaceUserRemovedEvent(user, space)
+    publish_event(event=event, topic=PubSubTopic.OKUNA.value)
diff --git a/openbook_communities/events/events.py b/openbook_communities/events/events.py
deleted file mode 100644
index 05eaa08d121955856ebb5490bccf2b1578b4ccca..0000000000000000000000000000000000000000
--- a/openbook_communities/events/events.py
+++ /dev/null
@@ -1,115 +0,0 @@
-from enum import Enum
-
-from openbook_common.pubsub.event import Event
-from openbook_communities.events.payloads import SpacePayload, UserPayload
-
-
-class EventType(Enum):
-    SPACE_CREATED = "SpaceCreated"
-    SPACE_UPDATED = "SpaceUpdated"
-    SPACE_USER_ADDED = "SpaceUserAdded"
-    USER_NAME_UPDATED = "UserNameUpdated"
-    USER_EMAIL_UPDATED = "UserEmailUpdated"
-    SPACE_DELETED = "SpaceDeleted"
-    USER_DELETED = "UserDeleted"
-    SPACE_USER_LEFT = "SpaceUserLeft"
-    SPACE_USER_REMOVED = "SpaceUserRemoved"
-
-
-class SpaceCreatedEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, creator: UserPayload, space: SpacePayload):
-        super().__init__(
-            data={"creator": creator.to_dict(), "space": space.to_dict()},
-            event_type=EventType.SPACE_CREATED.value,
-            event_version=SpaceCreatedEvent.EVENT_VERSION,
-        )
-
-
-class SpaceUpdatedEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, creator: UserPayload, space: SpacePayload):
-        super().__init__(
-            data={"creator": creator.to_dict(), "space": space.to_dict()},
-            event_type=EventType.SPACE_UPDATED.value,
-            event_version=SpaceUpdatedEvent.EVENT_VERSION,
-        )
-
-
-class SpaceUserAddedEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, user: UserPayload, space: SpacePayload):
-        super().__init__(
-            data={"user": user.to_dict(), "space": space.to_dict()},
-            event_type=EventType.SPACE_USER_ADDED.value,
-            event_version=SpaceUserAddedEvent.EVENT_VERSION,
-        )
-
-
-class UserNameUpdatedEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, user: UserPayload):
-        super().__init__(
-            data={"user": user.to_dict()},
-            event_type=EventType.USER_NAME_UPDATED.value,
-            event_version=UserNameUpdatedEvent.EVENT_VERSION,
-        )
-
-
-class UserEmailUpdatedEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, user: UserPayload):
-        super().__init__(
-            data={"user": user.to_dict()},
-            event_type=EventType.USER_EMAIL_UPDATED.value,
-            event_version=UserEmailUpdatedEvent.EVENT_VERSION,
-        )
-
-
-class SpaceDeletedEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, space: SpacePayload):
-        super().__init__(
-            data={"space": space.to_dict()},
-            event_type=EventType.SPACE_DELETED.value,
-            event_version=SpaceDeletedEvent.EVENT_VERSION,
-        )
-
-
-class UserDeletedEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, user: UserPayload):
-        super().__init__(
-            data={"user": user.to_dict()},
-            event_type=EventType.USER_DELETED.value,
-            event_version=UserDeletedEvent.EVENT_VERSION,
-        )
-
-
-class SpaceUserLeftEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, user: UserPayload, space: SpacePayload):
-        super().__init__(
-            data={"user": user.to_dict(), "space": space.to_dict()},
-            event_type=EventType.SPACE_USER_LEFT.value,
-            event_version=SpaceUserLeftEvent.EVENT_VERSION,
-        )
-
-
-class SpaceUserRemovedEvent(Event):
-    EVENT_VERSION = "1.0.0"
-
-    def __init__(self, user: UserPayload, space: SpacePayload):
-        super().__init__(
-            data={"user": user.to_dict(), "space": space.to_dict()},
-            event_type=EventType.SPACE_USER_REMOVED.value,
-            event_version=SpaceUserRemovedEvent.EVENT_VERSION,
-        )
diff --git a/openbook_communities/events/payloads.py b/openbook_communities/events/payloads.py
deleted file mode 100644
index 53bdcf55767acdacb80d70f926683b8ebf34ea7f..0000000000000000000000000000000000000000
--- a/openbook_communities/events/payloads.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from openbook_communities.models import Community
-from openbook_auth.models import User
-
-
-class UserPayload:
-    def __init__(self, id: str, name: str, email: str, identity: str, avatar: str = ""):
-        self.id = id
-        self.name = name
-        self.email = email
-        self.identity = identity
-        self.avatar = avatar
-
-    def to_dict(self):
-        return {
-            "id": self.id,
-            "name": self.name,
-            "email": self.email,
-            "identity": self.identity,
-            "avatar": self.avatar,
-        }
-
-    @classmethod
-    def from_user(cls, user: User):
-        avatar_url = user.profile.avatar.url if user.profile.avatar else None
-        return cls(
-            id=str(user.username),
-            name=user.profile.full_name(),
-            email=user.email,
-            identity=user.identity,
-            avatar=avatar_url,
-        )
-
-
-class SpacePayload:
-    def __init__(self, id: str, name: str, slug: str, avatar: str, avatar_default_color: str):
-        self.id = id
-        self.name = name
-        self.avatar = avatar
-        self.slug = slug
-        self.avatarDefaultColor = avatar_default_color
-
-    def to_dict(self):
-        return {
-            "id": self.id,
-            "name": self.name,
-            "slug": self.slug,
-            "avatar": self.avatar,
-            "avatarDefaultColor": self.avatarDefaultColor,
-        }
-
-    @classmethod
-    def from_community(cls, community: Community):
-        if community.avatar:
-            avatarUrl = community.avatar.url
-        else:
-            avatarUrl = None
-
-        return cls(
-            id=str(community.id),
-            name=community.title,
-            slug=community.name,
-            avatar=avatarUrl,
-            avatar_default_color=community.avatar_default_color,
-        )
diff --git a/openbook_communities/migrations/0041_alter_community_name_alter_community_title.py b/openbook_communities/migrations/0041_alter_community_name_alter_community_title.py
new file mode 100644
index 0000000000000000000000000000000000000000..a336e8aee8ed0cfc3d04ea0801fc5bf50c9f48c7
--- /dev/null
+++ b/openbook_communities/migrations/0041_alter_community_name_alter_community_title.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.0.9 on 2024-12-17 09:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("openbook_communities", "0040_alter_task_creator"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="community",
+            name="name",
+            field=models.SlugField(max_length=80, unique=True, verbose_name="name"),
+        ),
+        migrations.AlterField(
+            model_name="community",
+            name="title",
+            field=models.CharField(max_length=80, verbose_name="title"),
+        ),
+    ]
diff --git a/openbook_communities/schema/mutations.py b/openbook_communities/schema/mutations.py
index 1089510cf5a547a9426ec0907c3bf9ddc01e02be..2c1be6a8b289ccbeda9c6125df4dc4d0d8e07f26 100644
--- a/openbook_communities/schema/mutations.py
+++ b/openbook_communities/schema/mutations.py
@@ -8,11 +8,11 @@ from django.db import transaction
 from graphql import GraphQLError
 
 from openbook.graphql_errors import GraphQLErrorCodes
-from openbook.helpers import (
+from openbook_communities.events import (
     publish_space_created_event,
     publish_space_updated_event,
-    publish_space_deleted_event,
     publish_space_user_added_event,
+    publish_space_deleted_event,
     publish_space_user_left_event,
     publish_space_user_removed_event,
 )
diff --git a/openbook_communities/tests/test_events.py b/openbook_communities/tests/test_events.py
index 55fc3f5f5d874875eabcb4f5d8916237ee1688c5..fea9d6a231b559ed06b04d67d895f2f36e9a6d3e 100644
--- a/openbook_communities/tests/test_events.py
+++ b/openbook_communities/tests/test_events.py
@@ -1,156 +1,172 @@
 import json
+from typing import Any
 
-from django.test import testcases
+import pytest
 
+from openbook_auth.events import UserPayload
+from openbook_auth.models import User
 from openbook_common.tests.helpers import make_community, make_user
-from openbook_communities.events.events import (
-    SpaceCreatedEvent,
+from openbook_communities.events import (
+    SpacePayload,
+    SpaceUserRemovedEvent,
+    SpaceUserLeftEvent,
     SpaceDeletedEvent,
     SpaceUserAddedEvent,
-    SpaceUserLeftEvent,
-    SpaceUserRemovedEvent,
-    UserDeletedEvent,
-    UserNameUpdatedEvent,
+    SpaceCreatedEvent,
+    SpaceUpdatedEvent,
 )
-from openbook_communities.events.payloads import SpacePayload, UserPayload
-
-
-class SpaceCreatedEventTestCase(testcases.TestCase):
-    def test_should_create_space_created_event(self):
-        user = make_user()
-        community = make_community(creator=user)
-
-        creator = UserPayload.from_user(user)
-        space = SpacePayload.from_community(community)
-
-        data = {"creator": creator.to_dict(), "space": space.to_dict()}
-
-        encoded_data = json.dumps(data).encode("utf-8")
-
-        event = SpaceCreatedEvent(creator, space)
-
-        event_dict = event.to_dict()
-
-        eventPayload = {"eventType": "SpaceCreated", "eventVersion": "1.0.0", "data": encoded_data}
-
-        self.assertEqual(eventPayload, event_dict)
-
-
-class SpaceUserAddedEventTestCase(testcases.TestCase):
-    def test_should_create_space_user_added_event(self):
-        user = make_user()
-        community = make_community()
-
-        user = UserPayload.from_user(user)
-        space = SpacePayload.from_community(community)
-
-        data = {"user": user.to_dict(), "space": space.to_dict()}
-
-        encoded_data = json.dumps(data).encode("utf-8")
-
-        event = SpaceUserAddedEvent(user, space)
-
-        event_dict = event.to_dict()
-
-        eventPayload = {"eventType": "SpaceUserAdded", "eventVersion": "1.0.0", "data": encoded_data}
-
-        self.assertEqual(eventPayload, event_dict)
-
-
-class UserNameUpdatedEventTestCase(testcases.TestCase):
-    def test_should_create_user_name_updated_event(self):
-        user = make_user()
-
-        user_payload = UserPayload.from_user(user)
-
-        data = {"user": user_payload.to_dict()}
-
-        encoded_data = json.dumps(data).encode("utf-8")
-
-        event = UserNameUpdatedEvent(user_payload)
-
-        event_dict = event.to_dict()
-
-        eventPayload = {"eventType": "UserNameUpdated", "eventVersion": "1.0.0", "data": encoded_data}
-
-        self.assertEqual(eventPayload, event_dict)
-
-
-class SpaceDeletedEventTestCase(testcases.TestCase):
-    def test_should_create_space_deleted_event(self):
-        community = make_community()
-
-        space = SpacePayload.from_community(community)
-
-        data = {"space": space.to_dict()}
-
-        encoded_data = json.dumps(data).encode("utf-8")
-
-        event = SpaceDeletedEvent(space)
-
-        event_dict = event.to_dict()
-
-        eventPayload = {"eventType": "SpaceDeleted", "eventVersion": "1.0.0", "data": encoded_data}
-
-        self.assertEqual(eventPayload, event_dict)
-
-
-class UserDeletedEventTestCase(testcases.TestCase):
-    def test_should_create_space_deleted_event(self):
-        user = make_user()
-
-        user = UserPayload.from_user(user)
-
-        data = {"user": user.to_dict()}
-
-        encoded_data = json.dumps(data).encode("utf-8")
-
-        event = UserDeletedEvent(user)
-
-        event_dict = event.to_dict()
-
-        eventPayload = {"eventType": "UserDeleted", "eventVersion": "1.0.0", "data": encoded_data}
-
-        self.assertEqual(eventPayload, event_dict)
-
-
-class SpaceUserLeftEventTestCase(testcases.TestCase):
-    def test_should_create_space_user_left_event(self):
-        user = make_user()
-        community = make_community()
-
-        user = UserPayload.from_user(user)
-        space = SpacePayload.from_community(community)
-
-        data = {"user": user.to_dict(), "space": space.to_dict()}
-
-        encoded_data = json.dumps(data).encode("utf-8")
-
-        event = SpaceUserLeftEvent(user, space)
-
-        event_dict = event.to_dict()
-
-        eventPayload = {"eventType": "SpaceUserLeft", "eventVersion": "1.0.0", "data": encoded_data}
-
-        self.assertEqual(eventPayload, event_dict)
-
-
-class SpaceUserRemovedEventTestCase(testcases.TestCase):
-    def test_should_create_space_user_removed_event(self):
-        user = make_user()
-        community = make_community()
-
-        user = UserPayload.from_user(user)
-        space = SpacePayload.from_community(community)
-
-        data = {"user": user.to_dict(), "space": space.to_dict()}
-
-        encoded_data = json.dumps(data).encode("utf-8")
-
-        event = SpaceUserRemovedEvent(user, space)
-
-        event_dict = event.to_dict()
-
-        eventPayload = {"eventType": "SpaceUserRemoved", "eventVersion": "1.0.0", "data": encoded_data}
-
-        self.assertEqual(eventPayload, event_dict)
+from openbook_communities.models import Community
+
+
+# noinspection DuplicatedCode
+def expected_user_payload(user: User) -> dict[str, Any]:
+    return {
+        "id": str(user.username),
+        "name": user.profile.full_name(),
+        "email": user.email,
+        "identity": user.identity,
+        "avatar": user.profile.avatar.url if user.profile.avatar else None,
+        "aboutMe": user.profile.about_me,
+        "location": user.profile.location,
+    }
+
+
+# noinspection DuplicatedCode
+def expected_space_payload(community: Community) -> dict[str, Any]:
+    return {
+        "id": str(community.id),
+        "name": community.title,
+        "slug": community.name,
+        "avatar": community.avatar.url if community.avatar else None,
+        "description": community.description,
+        "location": community.location,
+        "locationLatLng": (
+            {
+                "latitude": community.geolocation.x,
+                "longitude": community.geolocation.y,
+            }
+            if community.geolocation
+            else None
+        ),
+    }
+
+
+@pytest.mark.django_db
+def test_serialization_space_created_event():
+    # GIVEN
+    user = make_user()
+    space = make_community(creator=user)
+
+    # WHEN
+    event = SpaceCreatedEvent(UserPayload.from_user(user), SpacePayload.from_community(space))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "SpaceCreated",
+        "eventVersion": "1.1.0",
+        "data": (
+            json.dumps({"creator": expected_user_payload(user), "space": expected_space_payload(space)}).encode(
+                "utf-8"
+            )
+        ),
+    }
+    assert event.to_dict() == expected_event_dict
+
+
+@pytest.mark.django_db
+def test_serialization_space_updated_event():
+    # GIVEN
+    user = make_user()
+    space = make_community(creator=user)
+
+    # WHEN
+    event = SpaceUpdatedEvent(UserPayload.from_user(user), SpacePayload.from_community(space))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "SpaceUpdated",
+        "eventVersion": "1.1.0",
+        "data": (
+            json.dumps({"creator": expected_user_payload(user), "space": expected_space_payload(space)}).encode(
+                "utf-8"
+            )
+        ),
+    }
+    assert event.to_dict() == expected_event_dict
+
+
+@pytest.mark.django_db
+def test_serialization_space_deleted_event():
+    # GIVEN
+    space = make_community()
+
+    # WHEN
+    event = SpaceDeletedEvent(SpacePayload.from_community(space))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "SpaceDeleted",
+        "eventVersion": "1.1.0",
+        "data": (json.dumps({"space": expected_space_payload(space)}).encode("utf-8")),
+    }
+    assert event.to_dict() == expected_event_dict
+
+
+@pytest.mark.django_db
+def test_serialization_space_user_added_event():
+    # GIVEN
+    user = make_user()
+    space = make_community()
+
+    # WHEN
+    event = SpaceUserAddedEvent(UserPayload.from_user(user), SpacePayload.from_community(space))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "SpaceUserAdded",
+        "eventVersion": "1.1.0",
+        "data": (
+            json.dumps({"user": expected_user_payload(user), "space": expected_space_payload(space)}).encode("utf-8")
+        ),
+    }
+    assert event.to_dict() == expected_event_dict
+
+
+@pytest.mark.django_db
+def test_serialization_space_user_left_event():
+    # GIVEN
+    user = make_user()
+    space = make_community()
+
+    # WHEN
+    event = SpaceUserLeftEvent(UserPayload.from_user(user), SpacePayload.from_community(space))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "SpaceUserLeft",
+        "eventVersion": "1.1.0",
+        "data": (
+            json.dumps({"user": expected_user_payload(user), "space": expected_space_payload(space)}).encode("utf-8")
+        ),
+    }
+    assert event.to_dict() == expected_event_dict
+
+
+@pytest.mark.django_db
+def test_serialization_space_user_removed_event():
+    # GIVEN
+    user = make_user()
+    space = make_community()
+
+    # WHEN
+    event = SpaceUserRemovedEvent(UserPayload.from_user(user), SpacePayload.from_community(space))
+
+    # THEN
+    expected_event_dict = {
+        "eventType": "SpaceUserRemoved",
+        "eventVersion": "1.1.0",
+        "data": (
+            json.dumps({"user": expected_user_payload(user), "space": expected_space_payload(space)}).encode("utf-8")
+        ),
+    }
+    assert event.to_dict() == expected_event_dict
diff --git a/openbook_communities/tests/test_graphql_communities.py b/openbook_communities/tests/test_graphql_communities.py
index b81aa4b2a3c4167e53bd8cfceb588eea9ecfd74e..834ff8b02aee303257be26e47c935e1303f0101e 100644
--- a/openbook_communities/tests/test_graphql_communities.py
+++ b/openbook_communities/tests/test_graphql_communities.py
@@ -17,6 +17,7 @@ from django.test import AsyncClient
 from requests.models import Response
 
 from openbook.graphql_schema import schema
+from openbook.settings import COMMUNITY_TITLE_MAX_LENGTH, COMMUNITY_DESCRIPTION_MAX_LENGTH
 from openbook.tests.test_data import geolocation, geolocation_geometry
 from openbook_common.enums import VisibilityType
 from openbook_common.tests.helpers import (
@@ -2549,10 +2550,14 @@ class TestCommunities(TestCase):
             {"field": "title", "value": "", "expected_code": "blank"},
             {"field": "title", "value": "  ", "expected_code": "blank"},
             {"field": "title", "value": "f", "expected_code": "min_length"},
-            {"field": "title", "value": "f" * 61, "expected_code": "max_length"},
+            {"field": "title", "value": "f" * (COMMUNITY_TITLE_MAX_LENGTH + 1), "expected_code": "max_length"},
             {"field": "description", "value": None, "expected_code": "null"},
             {"field": "description", "value": "", "expected_code": "blank"},
-            {"field": "description", "value": "f" * 601, "expected_code": "max_length"},
+            {
+                "field": "description",
+                "value": "f" * (COMMUNITY_DESCRIPTION_MAX_LENGTH + 1),
+                "expected_code": "max_length",
+            },
             {"field": "topics", "value": None, "expected_code": "null"},
             {"field": "topics", "value": [], "expected_code": "min_length"},
             {"field": "topics", "value": topic_ids, "expected_code": "max_length"},
@@ -2709,10 +2714,14 @@ class TestCommunities(TestCase):
             {"field": "title", "value": None, "expected_error": "Expected non-nullable type"},
             {"field": "title", "value": "", "expected_code": "blank"},
             {"field": "title", "value": "f", "expected_code": "min_length"},
-            {"field": "title", "value": "f" * 61, "expected_code": "max_length"},
+            {"field": "title", "value": "f" * (COMMUNITY_TITLE_MAX_LENGTH + 1), "expected_code": "max_length"},
             {"field": "description", "value": None, "expected_error": "Expected non-nullable type"},
             {"field": "description", "value": "", "expected_code": "blank"},
-            {"field": "description", "value": "f" * 601, "expected_code": "max_length"},
+            {
+                "field": "description",
+                "value": "f" * (COMMUNITY_DESCRIPTION_MAX_LENGTH + 1),
+                "expected_code": "max_length",
+            },
             {"field": "topics", "value": None, "expected_error": "Expected non-nullable type"},
             {"field": "topics", "value": [], "expected_code": "min_length"},
             {"field": "topics", "value": topic_ids, "expected_code": "max_length"},
@@ -2848,9 +2857,13 @@ class TestCommunities(TestCase):
             {"field": "id", "value": invalid_uuid, "expected_error": "No community with the provided id exists."},
             {"field": "title", "value": "", "expected_code": "blank"},
             {"field": "title", "value": "f", "expected_code": "min_length"},
-            {"field": "title", "value": "f" * 61, "expected_code": "max_length"},
+            {"field": "title", "value": "f" * (COMMUNITY_TITLE_MAX_LENGTH + 1), "expected_code": "max_length"},
             {"field": "description", "value": "", "expected_code": "blank"},
-            {"field": "description", "value": "f" * 601, "expected_code": "max_length"},
+            {
+                "field": "description",
+                "value": "f" * (COMMUNITY_DESCRIPTION_MAX_LENGTH + 1),
+                "expected_code": "max_length",
+            },
             {"field": "topics", "value": [], "expected_code": "min_length"},
             {"field": "topics", "value": topic_ids, "expected_code": "max_length"},
             {
diff --git a/openbook_communities/tests/test_helpers.py b/openbook_communities/tests/test_helpers.py
index fcb5eded3eaa861c9c5e982e69a63e34b63775d7..6116e8fa9bf3c1325b8b3ba326b0ac7c131c6d07 100644
--- a/openbook_communities/tests/test_helpers.py
+++ b/openbook_communities/tests/test_helpers.py
@@ -1,5 +1,6 @@
 from django.db.models import QuerySet
 
+from openbook.settings import COMMUNITY_NAME_MAX_LENGTH
 from openbook_auth.models import UserRole
 from openbook_common.tests.helpers import make_community, make_user
 from openbook_common.tests.models import OpenbookAPITestCase
@@ -10,7 +11,7 @@ from openbook_communities.models import CommunityMembership
 class CommunitiesHelperTests(OpenbookAPITestCase):
     def test_create_community_name_from_title_and_hash(self):
         hash = "1234567890"
-        long_name = "f" * 60
+        long_name = "f" * COMMUNITY_NAME_MAX_LENGTH
         make_community(title="bar")
         make_community(title="Some Title")
         make_community(title="bar-12345678")
@@ -23,7 +24,7 @@ class CommunitiesHelperTests(OpenbookAPITestCase):
         self.assertEqual("some-title-23456789", create_community_name_from_title_and_hash("Some Title", "2345678901"))
         self.assertEqual("bar-123456789", create_community_name_from_title_and_hash("bar", hash))
         self.assertEqual("bar-1234", create_community_name_from_title_and_hash("bar", "1234"))
-        self.assertEqual(f"{long_name[:51]}-12345678", create_community_name_from_title_and_hash(long_name, hash))
+        self.assertEqual(f"{long_name[:71]}-12345678", create_community_name_from_title_and_hash(long_name, hash))
 
         with self.assertRaises(Exception) as context:
             create_community_name_from_title_and_hash("bar", "12345678")
diff --git a/openbook_notifications/notifications.py b/openbook_notifications/notifications.py
index 61be89329e714a7530b09a90e5a1bc96462edb4f..cafdafff30a0c1c9685dc83eb31ce559e7f82d27 100644
--- a/openbook_notifications/notifications.py
+++ b/openbook_notifications/notifications.py
@@ -28,6 +28,7 @@ from openbook_notifications.payloads import (
     PollUpdatesPayload,
     PostReactionPayload,
     CommentReactionPayload,
+    WelcomeEmailSeriesPayload,
 )
 
 
@@ -51,6 +52,7 @@ class Workflow(Enum):
     POLL_UPDATES = "poll-updates"
     POST_REACTIONS = "post-reactions"
     COMMENT_REACTIONS = "comment-reactions"
+    WELCOME_EMAIL_SERIES = "welcome-email-series"
 
 
 # INTERNAL USE ONLY, use NotificationsService  to trigger notifications
@@ -382,3 +384,17 @@ class CommentReactionNotification(Notification):
             payload=payload.to_dict(),
             transaction_id=transaction_id if transaction_id else str(uuid.uuid4()),
         )
+
+
+class WelcomeEmailSeriesNotification(Notification):
+    def __init__(
+        self,
+        user: str,
+        payload: WelcomeEmailSeriesPayload,
+    ):
+        super().__init__(
+            workflow=Workflow.WELCOME_EMAIL_SERIES.value,
+            recipients=user,
+            payload=payload.to_dict(),
+            transaction_id=user,  # user id is used as transaction id so that we can easily unsubscribe from the email series
+        )
diff --git a/openbook_notifications/payloads.py b/openbook_notifications/payloads.py
index 8000930ac2a9d877dd8325959473c8246c8d7e58..910d409b4211bef9b6fca8c0ce8eb42cd2de2ec3 100644
--- a/openbook_notifications/payloads.py
+++ b/openbook_notifications/payloads.py
@@ -36,6 +36,10 @@ class NotificationType(Enum):
     POLL_UPDATES = "POLL_UPDATES"
     POST_REACTION = "POST_REACTION"
     COMMENT_REACTION = "COMMENT_REACTION"
+    WELCOME_EMAIL_SERIES = "WELCOME_EMAIL_SERIES"
+
+
+app_url = settings.APP_URL
 
 
 class UserData:
@@ -43,6 +47,7 @@ class UserData:
         self,
         id: str,
         full_name: str,
+        profile_link: str,
         avatar_blurhash: Optional[str] = "",
         avatar_default_color: Optional[str] = "",
         avatar: Optional[str] = "",
@@ -54,6 +59,7 @@ class UserData:
         self.avatar_blurhash = avatar_blurhash
         self.avatar_default_color = avatar_default_color
         self.avatar_label = avatar_label
+        self.profile_link = profile_link
 
     def to_dict(self):
         return {
@@ -63,6 +69,7 @@ class UserData:
             "avatarBlurhash": self.avatar_blurhash,
             "avatarDefaultColor": self.avatar_default_color,
             "avatarLabel": self.avatar_label,
+            "profileLink": self.profile_link,
         }
 
     @classmethod
@@ -74,6 +81,7 @@ class UserData:
             avatar_blurhash=user.profile.avatar_blurhash if user.profile.avatar_blurhash else "",
             avatar_default_color=str(user.avatar_default_color) if user.avatar_default_color else "",
             avatar_label=user.avatar_label,
+            profile_link=f"{app_url}/profile/{user.username}",
         )
 
 
@@ -83,6 +91,9 @@ class SpaceData:
         id: str,
         title: str,
         name: str,
+        goal: str,
+        link: str,
+        cover: str,
         avatar_blurhash: str = "",
         avatar_default_color: str = "",
         avatar: Optional[str] = "",
@@ -90,18 +101,24 @@ class SpaceData:
         self.id = id
         self.name = name
         self.title = title
+        self.goal = goal
         self.avatar = avatar
         self.avatar_blurhash = avatar_blurhash
         self.avatar_default_color = avatar_default_color
+        self.link = link
+        self.cover = cover
 
     def to_dict(self):
         return {
             "id": self.id,
             "name": self.name,
             "title": self.title,
+            "goal": self.goal,
             "avatar": self.avatar,
             "avatarBlurhash": self.avatar_blurhash,
             "avatarDefaultColor": self.avatar_default_color,
+            "link": self.link,
+            "cover": self.cover,
         }
 
     @classmethod
@@ -110,9 +127,12 @@ class SpaceData:
             id=str(community.id),
             name=community.name,
             title=community.title,
+            goal=community.description,
             avatar=community.avatar.url if community.avatar else "",
             avatar_blurhash=community.avatar_blurhash if community.avatar_blurhash else "",
             avatar_default_color=str(community.avatar_default_color) if community.avatar_default_color else "",
+            link=f"{app_url}/spaces/{community.name}",
+            cover=community.cover.url if community.cover else "",
         )
 
 
@@ -227,6 +247,38 @@ class PostData:
         )
 
 
+class InsightData:
+    def __init__(
+        self,
+        id: str,
+        title: str,
+        cover: str,
+        link: str,
+    ):
+        self.id = id
+        self.title = title
+        self.cover = cover
+        self.link = link
+
+    def to_dict(self):
+        data = {
+            "id": self.id,
+            "title": self.title,
+            "cover": self.cover,
+            "link": self.link,
+        }
+        return data
+
+    @classmethod
+    def from_insight(cls, insight):
+        return cls(
+            id=str(insight.id),
+            title=insight.title,
+            cover=insight.header_image.url,
+            link=f"{app_url}/insights/{insight.id}",
+        )
+
+
 class CommentData:
     def __init__(self, id: str, text: str):
         self.id = id
@@ -556,3 +608,18 @@ class CommentReactionPayload(Payload):
             notification_type=NotificationType.COMMENT_REACTION.value,
             notification_version=CommentReactionPayload.NOTIFICATION_VERSION,
         )
+
+
+class WelcomeEmailSeriesPayload(Payload):
+    NOTIFICATION_VERSION = "1.0.0"
+
+    def __init__(self, user: User, spaces, insights):
+        super().__init__(
+            data={
+                "user": UserData.from_user(user).to_dict(),
+                "recommendedSpaces": [SpaceData.from_community(space).to_dict() for space in spaces],
+                "recommendedInsights": [InsightData.from_insight(insight).to_dict() for insight in insights],
+            },
+            notification_type=NotificationType.WELCOME_EMAIL_SERIES.value,
+            notification_version=WelcomeEmailSeriesPayload.NOTIFICATION_VERSION,
+        )
diff --git a/openbook_notifications/services.py b/openbook_notifications/services.py
index 59e9020c63d2285ab543f65fe63ae0e1284cf5b6..8aa843b684c17eaf12ecf0bc6132cc73cf8de503 100644
--- a/openbook_notifications/services.py
+++ b/openbook_notifications/services.py
@@ -27,6 +27,7 @@ from openbook_notifications.notifications import (
     PollUpdatesNotification,
     CommentReactionNotification,
     PostReactionNotification,
+    WelcomeEmailSeriesNotification,
 )
 from openbook_notifications.payloads import (
     SpaceMembershipRequestPayload,
@@ -46,6 +47,7 @@ from openbook_notifications.payloads import (
     PollUpdatesPayload,
     PostReactionPayload,
     CommentReactionPayload,
+    WelcomeEmailSeriesPayload,
 )
 
 if TYPE_CHECKING:
@@ -358,3 +360,11 @@ class NotificationsService:
             payload=CommentReactionPayload(reactor=reactor, comment=comment, context_id=context_id),
         )
         notification.send()
+
+    @staticmethod
+    def send_welcome_email_series_notification(user: User, spaces, insights):
+        notification = WelcomeEmailSeriesNotification(
+            user=str(user.username),
+            payload=WelcomeEmailSeriesPayload(user=user, spaces=spaces, insights=insights),
+        )
+        notification.send()
diff --git a/openbook_notifications/tests/test_payloads.py b/openbook_notifications/tests/test_payloads.py
index e30bddfd1b8b636473926ce400d8b918737cc565..2b108dd58065ec1192b4e5fc8b2702554d349ada 100644
--- a/openbook_notifications/tests/test_payloads.py
+++ b/openbook_notifications/tests/test_payloads.py
@@ -4,6 +4,7 @@ import pytest
 from django.test import TestCase
 from novu.api import EventApi
 
+from openbook import settings
 from openbook_common.tests.helpers import (
     make_community,
     make_community_post,
@@ -64,6 +65,7 @@ class TestPayloads(TestCase):
             avatar_blurhash="test_blurhash",
             avatar_default_color="#FFF000",
             avatar_label="TU",
+            profile_link=f"{settings.APP_URL}/profile/test-user-id",
         )
         result = user_data.to_dict()
 
@@ -76,6 +78,7 @@ class TestPayloads(TestCase):
                 "avatarBlurhash": "test_blurhash",
                 "avatarDefaultColor": "#FFF000",
                 "avatarLabel": "TU",
+                "profileLink": f"{settings.APP_URL}/profile/test-user-id",
             },
         )
 
@@ -92,29 +95,36 @@ class TestPayloads(TestCase):
                 "avatarBlurhash": "",
                 "avatarDefaultColor": self.user.avatar_default_color,
                 "avatarLabel": self.user.avatar_label,
+                "profileLink": f"{settings.APP_URL}/profile/{self.user.username}",
             },
         )
 
     def test_space_data_to_dict(self):
         space_data = SpaceData(
-            id="test-space-id",
+            id=self.space.id,
             name="test-space",
             title="Test Space",
             avatar="avatar.jpg",
             avatar_blurhash="test-blurhash",
             avatar_default_color="#FFF000",
+            goal="test-goal",
+            link=f"{settings.APP_URL}/spaces/{self.space.id}",
+            cover="cover.jpg",
         )
         result = space_data.to_dict()
 
         self.assertEqual(
             result,
             {
-                "id": "test-space-id",
+                "id": self.space.id,
                 "name": "test-space",
                 "title": "Test Space",
                 "avatar": "avatar.jpg",
                 "avatarBlurhash": "test-blurhash",
                 "avatarDefaultColor": "#FFF000",
+                "goal": "test-goal",
+                "link": f"{settings.APP_URL}/spaces/{self.space.id}",
+                "cover": "cover.jpg",
             },
         )
 
@@ -131,6 +141,9 @@ class TestPayloads(TestCase):
                 "avatar": "",
                 "avatarBlurhash": "",
                 "avatarDefaultColor": self.space.avatar_default_color,
+                "goal": self.space.description,
+                "link": f"{settings.APP_URL}/spaces/{self.space.name}",
+                "cover": self.space.cover.url if self.space.cover else "",
             },
         )