diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 4709721355ee42e21408e6502ee2a9c8d58780c0..58c16ac1a88db237409c5299d86e70fe70f15265 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
 
 from django.contrib import auth
 
+from allauth.account import models as allauth_models
 from rest_auth.serializers import PasswordResetSerializer as PRS
 from rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter
 from rest_framework import serializers
@@ -288,3 +289,29 @@ class LoginSerializer(serializers.Serializer):
 
     def save(self, request):
         return auth.login(request, self.validated_data)
+
+
+class UserChangeEmailSerializer(serializers.Serializer):
+    password = serializers.CharField()
+    email = serializers.EmailField()
+
+    def validate_password(self, value):
+        if not self.instance.check_password(value):
+            raise serializers.ValidationError("Invalid password")
+
+    def validate_email(self, value):
+        if (
+            allauth_models.EmailAddress.objects.filter(email__iexact=value)
+            .exclude(user=self.context["user"])
+            .exists()
+        ):
+            raise serializers.ValidationError("This email address is already in use")
+        return value
+
+    def save(self, request):
+        current, _ = allauth_models.EmailAddress.objects.get_or_create(
+            user=request.user,
+            email=request.user.email,
+            defaults={"verified": False, "primary": True},
+        )
+        current.change(request, self.validated_data["email"], confirm=True)
diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index 5a8b2b07f99a9c5fe0a2d7e6d199e3fbd588fd9e..f177bb1e409b1d7a44e8efa12cb437e2554054ea 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -111,6 +111,22 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
         data = {"subsonic_api_token": self.request.user.subsonic_api_token}
         return Response(data)
 
+    @action(
+        methods=["post"],
+        required_scope="security",
+        url_path="change-email",
+        detail=False,
+    )
+    def change_email(self, request, *args, **kwargs):
+        if not self.request.user.is_authenticated:
+            return Response(status=403)
+        serializer = serializers.UserChangeEmailSerializer(
+            request.user, data=request.data, context={"user": request.user}
+        )
+        serializer.is_valid(raise_exception=True)
+        serializer.save(request)
+        return Response(status=204)
+
     def update(self, request, *args, **kwargs):
         if not self.request.user.username == kwargs.get("username"):
             return Response(status=403)
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 691ed5cfe97a7bf2aa88e0fda895493c900f50df..9b30c78bb4c0301daa526610e73ccb02885bf831 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -568,3 +568,26 @@ def test_update_settings(logged_in_api_client, factories):
     logged_in_api_client.user.refresh_from_db()
 
     assert logged_in_api_client.user.settings == {"foo": "bar", "theme": "dark"}
+
+
+def test_user_change_email_requires_valid_password(logged_in_api_client):
+    url = reverse("api:v1:users:users-change-email")
+    payload = {"password": "invalid", "email": "test@new.email"}
+    response = logged_in_api_client.post(url, payload)
+
+    assert response.status_code == 400
+
+
+def test_user_change_email(logged_in_api_client, mocker, mailoutbox):
+    user = logged_in_api_client.user
+    user.set_password("mypassword")
+    url = reverse("api:v1:users:users-change-email")
+    payload = {"password": "mypassword", "email": "test@new.email"}
+    response = logged_in_api_client.post(url, payload)
+
+    address = user.emailaddress_set.latest("id")
+
+    assert address.email == payload["email"]
+    assert address.verified is False
+    assert response.status_code == 204
+    assert len(mailoutbox) == 1
diff --git a/changes/changelog.d/292.enhancement b/changes/changelog.d/292.enhancement
new file mode 100644
index 0000000000000000000000000000000000000000..8da9e9a13dffbe03c59d5b1412f97289d634a7f7
--- /dev/null
+++ b/changes/changelog.d/292.enhancement
@@ -0,0 +1 @@
+Users can now update their email address (#292)
\ No newline at end of file
diff --git a/docs/swagger.yml b/docs/swagger.yml
index e4b5563d462cf403971d03438aae6d1792cae357..0c3a0b1d98598d9929b0c4a12133ae2506a13a1a 100644
--- a/docs/swagger.yml
+++ b/docs/swagger.yml
@@ -310,6 +310,53 @@ paths:
             application/json:
               schema:
                 $ref: "./api/definitions.yml#/Me"
+    delete:
+      summary: Delete the user account performing the request
+      tags:
+        - "Auth and security"
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: "object"
+              properties:
+                confirm:
+                  type: "boolean"
+                  description: "Must be set to true, to avoid accidental deletion"
+                password:
+                  type: "string"
+                  description: "The current password of the account"
+      responses:
+        200:
+          content:
+            application/json:
+              schema:
+                $ref: "./api/definitions.yml#/Me"
+  /api/v1/users/users/change-email/:
+    post:
+      summary: Update the email address associated with a user account
+      tags:
+        - "Auth and security"
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: "object"
+              properties:
+                email:
+                  type: "string"
+                  format: "email"
+                password:
+                  type: "string"
+                  description: "The current password of the account"
+      responses:
+        200:
+          content:
+            application/json:
+              schema:
+                $ref: "./api/definitions.yml#/Me"
 
   /api/v1/rate-limit/:
     get:
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index f07d0dcf347de11c8a22d8569c7083dc9a5ef631..41c60fc44cfe1bef0a72d55a082fa84147486a60 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -270,6 +270,38 @@
           <translate translate-context="Content/Settings/Button.Label">Manage plugins</translate>
         </router-link>
       </section>
+      <section class="ui text container">
+        <div class="ui hidden divider"></div>
+        <h2 class="ui header">
+          <i class="comment icon"></i>
+          <div class="content">
+            <translate translate-context="*/*/Button.Label">Change my email address</translate>
+          </div>
+        </h2>
+        <p>
+          <translate translate-context="Content/Settings/Paragraph'">Change the email address associated with your account. We will send a confirmation to the new address.</translate>
+        </p>
+        <p>
+          <translate :translate-params="{email: $store.state.auth.profile.email}" translate-context="Content/Settings/Paragraph'">Your current email address is %{ email }.</translate>
+        </p>
+        <form class="ui form" @submit.prevent="changeEmail">
+          <div v-if="changeEmailErrors.length > 0" role="alert" class="ui negative message">
+            <h4 class="header"><translate translate-context="Content/Settings/Error message.Title">We cannot change your email address</translate></h4>
+            <ul class="list">
+              <li v-for="error in changeEmailErrors">{{ error }}</li>
+            </ul>
+          </div>
+          <div class="field">
+            <label for="new-email"><translate translate-context="*/*/*">New email</translate></label>
+            <input id="new-email" required v-model="newEmail" type="email" />
+          </div>
+          <div class="field">
+            <label for="current-password-field-email"><translate translate-context="*/*/*">Password</translate></label>
+            <password-input field-id="current-password-field-email" required v-model="emailPassword" />
+          </div>
+          <button type="submit" class="ui button"><translate translate-context="*/*/*">Update</translate></button>
+        </form>
+      </section>
       <section class="ui text container">
         <div class="ui hidden divider"></div>
         <h2 class="ui header">
@@ -339,6 +371,10 @@ export default {
       isLoading: false,
       isLoadingAvatar: false,
       isDeletingAccount: false,
+      changeEmailErrors: [],
+      isChangingEmail: false,
+      newEmail: null,
+      emailPassword: null,
       accountDeleteErrors: [],
       avatarErrors: [],
       apps: [],
@@ -519,6 +555,33 @@ export default {
           }
         )
     },
+
+    changeEmail() {
+      this.isChangingEmail = true
+      this.changeEmailErrors = []
+      let self = this
+      let payload = {
+        password: this.emailPassword,
+        email: this.newEmail,
+      }
+      axios.post(`users/users/change-email/`, payload)
+        .then(
+          response => {
+            self.isChangingEmail = false
+            self.newEmail = null
+            self.emailPassword = null
+            let msg = self.$pgettext('*/Auth/Message', 'Your email has been changed, please check your inbox for our confirmation message.')
+            self.$store.commit('ui/addMessage', {
+              content: msg,
+              date: new Date()
+            })
+          },
+          error => {
+            self.isChangingEmail = false
+            self.changeEmailErrors = error.backendErrors
+          }
+        )
+    },
   },
   computed: {
     labels() {