diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 7213bd074fce267adec2720033edd9f1a61c392c..af0f1bd680817e9e68aaf90b7b50bcf65b6dbcef 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -2,6 +2,7 @@
 
 module EmailsHelper
   include AppearancesHelper
+  include SafeFormatHelper
 
   # Google Actions
   # https://developers.google.com/gmail/markup/reference/go-to-action
@@ -236,6 +237,44 @@ def admin_changed_password_text(format: nil)
     end
   end
 
+  def member_about_to_expire_text(member_source, days_to_expire, format: nil)
+    days_formatted = pluralize(days_to_expire, 'day')
+
+    case member_source
+    when Project
+      url = project_url(member_source)
+    when Group
+      url = group_url(member_source)
+    end
+
+    case format
+    when :html
+      link_to = generate_link(member_source.human_name, url).html_safe
+      safe_format(_("Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}."), link_to: link_to, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted)
+    else
+      _("Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}.") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted }
+    end
+  end
+
+  def member_about_to_expire_link(member, member_source, format: nil)
+    project_or_group = member_source.human_name
+
+    case member_source
+    when Project
+      url = project_project_members_url(member_source, search: member.user.username)
+    when Group
+      url = group_group_members_url(member_source, search: member.user.username)
+    end
+
+    case format
+    when :html
+      link_to = generate_link("#{member_source.class.name.downcase} membership", url).html_safe
+      safe_format(_('For additional information, review your %{link_to} or contact your %{project_or_group} owner.'), link_to: link_to, project_or_group: project_or_group)
+    else
+      _('For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner.') % { project_or_group: project_or_group, url: url }
+    end
+  end
+
   def group_membership_expiration_changed_text(member, group)
     if member.expires?
       days = (member.expires_at - Date.today).to_i
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 33c955f94eeca92bb8a365368b5262b70b4c11ae..221d359c8c6f6f4121e8286144fd8d6e6a62d312 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -133,6 +133,22 @@ def member_expiration_date_updated_email(member_source_type, member_id)
         subject: subject(subject))
     end
 
+    def member_about_to_expire_email(member_source_type, member_id)
+      @member_source_type = member_source_type
+      @member_id = member_id
+
+      return unless member_exists?
+      return unless member.expires_at
+
+      @days_to_expire = (member.expires_at - Date.today).to_i
+
+      return if @days_to_expire <= 0
+
+      email_with_layout(
+        to: member.user.notification_email_for(notification_group),
+        subject: subject(s_("Your membership will expire in %{days_to_expire} days") % { days_to_expire: @days_to_expire }))
+    end
+
     # rubocop: disable CodeReuse/ActiveRecord
     def member
       @member ||= Member.find_by(id: @member_id)
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 93d4625c34421186738da8adce70732db6e947a6..4c6ae930cc56870008930472e506191dec0d7bb9 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -166,6 +166,13 @@ def member_invited_email
     Notify.member_invited_email('project', member.id, '1234').message
   end
 
+  def member_about_to_expire_email
+    cleanup do
+      member = project.add_member(user, Gitlab::Access::GUEST, expires_at: 7.days.from_now.to_date)
+      Notify.member_about_to_expire_email('project', member.id).message
+    end
+  end
+
   def pages_domain_enabled_email
     cleanup do
       pages_domain = PagesDomain.new(domain: 'my.example.com', project: project, verified_at: Time.now, enabled_until: 1.week.from_now)
diff --git a/app/models/member.rb b/app/models/member.rb
index f164ea244b4d525cf48bcb75ca26af74e8cb8caa..cdf40eaa8f5ddbf7b26d31d44ce7438820a3d848 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -153,6 +153,7 @@ class Member < ApplicationRecord
   scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) }
   scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) }
   scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) }
+  scope :expiring_and_not_notified, ->(date) { where("expiry_notified_at is null AND expires_at >= ? AND expires_at <= ?", Date.current, date) }
 
   scope :created_today, -> do
     now = Date.current
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
index b2c0fffc12d42cc802221613626229485db3bfcb..3a3d0e53aae5b013bc76be99e5a114859c6e1d80 100644
--- a/app/services/members/update_service.rb
+++ b/app/services/members/update_service.rb
@@ -36,6 +36,7 @@ def update_member(member, permission)
       member.attributes = params
       return unless member.changed?
 
+      member.expiry_notified_at = nil if member.expires_at_changed?
       member.tap(&:save!)
     end
 
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index d305c8c03cfd4846bfaf25b9034a71e987431b98..ceafebddfcf62795d3e80cb5222d938051322a88 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -529,6 +529,12 @@ def update_project_member(project_member)
     mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later
   end
 
+  def member_about_to_expire(member)
+    return true unless member.notifiable?(:mention)
+
+    mailer.member_about_to_expire_email(member.real_source_type, member.id).deliver_later
+  end
+
   # Group invite
   def invite_group_member(group_member, token)
     mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later
diff --git a/app/views/notify/member_about_to_expire_email.html.haml b/app/views/notify/member_about_to_expire_email.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..a9f92d90ae639f9a4ac700df3751c6ab06e9164c
--- /dev/null
+++ b/app/views/notify/member_about_to_expire_email.html.haml
@@ -0,0 +1,6 @@
+= email_default_heading(say_hi(@member.user))
+
+%p
+  = member_about_to_expire_text(@member_source, @days_to_expire, format: :html)
+%p
+  = member_about_to_expire_link(@member, @member_source, format: :html)
diff --git a/app/views/notify/member_about_to_expire_email.text.erb b/app/views/notify/member_about_to_expire_email.text.erb
new file mode 100644
index 0000000000000000000000000000000000000000..0c6e78bf5017240237c60c78fe8b9104c1438c4f
--- /dev/null
+++ b/app/views/notify/member_about_to_expire_email.text.erb
@@ -0,0 +1,5 @@
+<%= say_hi(@member.user) %>
+
+<%= member_about_to_expire_text(@member_source, @days_to_expire) %>
+
+<%= member_about_to_expire_link(@member, @member_source) %>
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 4f2d101239b4156a98ca5abf4728789f0f6fd670..e2d199b9e51e7e3323e95e88714e381b7e76a3f0 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -552,6 +552,15 @@
   :weight: 1
   :idempotent: false
   :tags: []
+- :name: cronjob:members_expiring
+  :worker_name: Members::ExpiringWorker
+  :feature_category: :system_access
+  :has_external_dependencies: false
+  :urgency: :low
+  :resource_boundary: :unknown
+  :weight: 1
+  :idempotent: false
+  :tags: []
 - :name: cronjob:metrics_global_metrics_update
   :worker_name: Metrics::GlobalMetricsUpdateWorker
   :feature_category: :metrics
@@ -2955,6 +2964,15 @@
   :weight: 2
   :idempotent:
   :tags: []
+- :name: members_expiring_email_notification
+  :worker_name: Members::ExpiringEmailNotificationWorker
+  :feature_category: :system_access
+  :has_external_dependencies: false
+  :urgency: :low
+  :resource_boundary: :unknown
+  :weight: 1
+  :idempotent: true
+  :tags: []
 - :name: merge
   :worker_name: MergeWorker
   :feature_category: :source_code_management
diff --git a/app/workers/members/expiring_email_notification_worker.rb b/app/workers/members/expiring_email_notification_worker.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1d0a6eb254a0ea0076b7a4204504a423a0ae5af7
--- /dev/null
+++ b/app/workers/members/expiring_email_notification_worker.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Members
+  class ExpiringEmailNotificationWorker # rubocop:disable Scalability/CronWorkerContext
+    include ApplicationWorker
+
+    data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency
+    feature_category :system_access
+    urgency :low
+    idempotent!
+
+    def perform(member_id)
+      notification_service = NotificationService.new
+      member = ::Member.find_by_id(member_id)
+
+      return unless member
+      return unless Feature.enabled?(:member_expiring_email_notification, member.source.root_ancestor)
+      return if member.expiry_notified_at.present?
+
+      with_context(user: member.user) do
+        notification_service.member_about_to_expire(member)
+        Gitlab::AppLogger.info(message: "Notifying user about expiring membership", member_id: member.id)
+
+        member.update(expiry_notified_at: Time.current)
+      end
+    end
+  end
+end
diff --git a/app/workers/members/expiring_worker.rb b/app/workers/members/expiring_worker.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0d631af3a7cc63eb6414652e7bdc172050c591bc
--- /dev/null
+++ b/app/workers/members/expiring_worker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Members
+  class ExpiringWorker # rubocop:disable Scalability/IdempotentWorker
+    include ApplicationWorker
+
+    # rubocop:disable Scalability/CronWorkerContext
+    # This worker does not perform work scoped to a context
+    include CronjobQueue
+    # rubocop:enable Scalability/CronWorkerContext
+
+    data_consistency :sticky
+    feature_category :system_access
+    urgency :low
+
+    BATCH_LIMIT = 500
+
+    def perform
+      return unless Feature.enabled?(:member_expiring_email_notification)
+
+      limit_date = Member::DAYS_TO_EXPIRE.days.from_now.to_date
+
+      expiring_members = Member.active.where(users: { user_type: :human }).expiring_and_not_notified(limit_date) # rubocop: disable CodeReuse/ActiveRecord
+
+      expiring_members.each_batch(of: BATCH_LIMIT) do |members|
+        members.pluck_primary_key.each do |member_id|
+          Members::ExpiringEmailNotificationWorker.perform_async(member_id)
+        end
+      end
+    end
+  end
+end
diff --git a/config/feature_flags/development/member_expiring_email_notification.yml b/config/feature_flags/development/member_expiring_email_notification.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1775cc67b52f8b4d97219e2eb39a8e1f08f1bca0
--- /dev/null
+++ b/config/feature_flags/development/member_expiring_email_notification.yml
@@ -0,0 +1,8 @@
+---
+name: member_expiring_email_notification
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124577
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/416581
+milestone: '16.3'
+type: development
+group: group::authentication and authorization
+default_enabled: false
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 50d26236a29e8b234a64009905c39702e11d97cd..cda625fab13ad1d82c930b5da5ac37eb68d73926 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -511,6 +511,9 @@
 Settings.cron_jobs['ci_archive_traces_cron_worker'] ||= {}
 Settings.cron_jobs['ci_archive_traces_cron_worker']['cron'] ||= '17 * * * *'
 Settings.cron_jobs['ci_archive_traces_cron_worker']['job_class'] = 'Ci::ArchiveTracesCronWorker'
+Settings.cron_jobs['members_expiring_worker'] ||= {}
+Settings.cron_jobs['members_expiring_worker']['cron'] ||= '0 1 * * *'
+Settings.cron_jobs['members_expiring_worker']['job_class'] = 'Members::ExpiringWorker'
 Settings.cron_jobs['remove_expired_members_worker'] ||= {}
 Settings.cron_jobs['remove_expired_members_worker']['cron'] ||= '10 0 * * *'
 Settings.cron_jobs['remove_expired_members_worker']['job_class'] = 'RemoveExpiredMembersWorker'
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 0205d24d48bd20a72fc70866a570a28242a37d08..7fe4db435a6d74df62c62afe31730eebfb011544 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -345,6 +345,8 @@
   - 2
 - - mailers
   - 2
+- - members_expiring_email_notification
+  - 1
 - - merge
   - 5
 - - merge_request_cleanup_refs
diff --git a/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb b/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e890325e5fa3fe02ca26849394fca57a0048cccb
--- /dev/null
+++ b/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AddExpiryNotifiedAtToMember < Gitlab::Database::Migration[2.1]
+  disable_ddl_transaction!
+
+  TABLE_NAME = 'members'
+  COLUMN_NAME = 'expiry_notified_at'
+
+  def up
+    with_lock_retries do
+      add_column(TABLE_NAME, COLUMN_NAME, :datetime_with_timezone)
+    end
+  end
+
+  def down
+    with_lock_retries do
+      remove_column TABLE_NAME, COLUMN_NAME
+    end
+  end
+end
diff --git a/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb b/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4d98d4792af3936fffedc3906a967132c22f727a
--- /dev/null
+++ b/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddIndexForMemberExpiringQuery < Gitlab::Database::Migration[2.1]
+  INDEX_NAME = 'index_members_on_expiring_at_access_level_id'
+
+  disable_ddl_transaction!
+
+  def up
+    add_concurrent_index :members,
+      [:expires_at, :access_level, :id],
+      where: 'requested_at IS NULL AND expiry_notified_at IS NULL',
+      name: INDEX_NAME
+  end
+
+  def down
+    remove_concurrent_index_by_name :members, INDEX_NAME
+  end
+end
diff --git a/db/schema_migrations/20230707003301 b/db/schema_migrations/20230707003301
new file mode 100644
index 0000000000000000000000000000000000000000..b1d5d84ee1bba2f8c936e4e9785e3bf26136e404
--- /dev/null
+++ b/db/schema_migrations/20230707003301
@@ -0,0 +1 @@
+dac0b6b1f86685bd19aed528c75197adfee06154ea68efdb854f4b17deb973fa
\ No newline at end of file
diff --git a/db/schema_migrations/20230714015909 b/db/schema_migrations/20230714015909
new file mode 100644
index 0000000000000000000000000000000000000000..c5362b4a82d07265c250d1c7211b7382e8682ecc
--- /dev/null
+++ b/db/schema_migrations/20230714015909
@@ -0,0 +1 @@
+54f82e196c756ca60c4d7300ef1987a6b1ca50d4ef87bf89bb62eb577164365c
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index d1f21674c21068702b896b695e8a935071e17075..18e82de6a74c55461eacedebf80882a8118e1957 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -18018,6 +18018,7 @@ CREATE TABLE members (
     invite_email_success boolean DEFAULT true NOT NULL,
     member_namespace_id bigint,
     member_role_id bigint,
+    expiry_notified_at timestamp with time zone,
     CONSTRAINT check_508774aac0 CHECK ((member_namespace_id IS NOT NULL))
 );
 
@@ -31872,6 +31873,8 @@ CREATE INDEX index_members_on_access_level ON members USING btree (access_level)
 
 CREATE INDEX index_members_on_expires_at ON members USING btree (expires_at);
 
+CREATE INDEX index_members_on_expiring_at_access_level_id ON members USING btree (expires_at, access_level, id) WHERE ((requested_at IS NULL) AND (expiry_notified_at IS NULL));
+
 CREATE INDEX index_members_on_invite_email ON members USING btree (invite_email);
 
 CREATE UNIQUE INDEX index_members_on_invite_token ON members USING btree (invite_token);
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 18019743fb568ffb5ef5e0c118150810146d25c3..d13b346d45032c0140955445b4ab24b86055cc43 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -19827,9 +19827,15 @@ msgstr ""
 msgid "For a faster browsing experience, some files are collapsed by default."
 msgstr ""
 
+msgid "For additional information, review your %{link_to} or contact your %{project_or_group} owner."
+msgstr ""
+
 msgid "For additional information, review your %{link_to} or contact your group owner."
 msgstr ""
 
+msgid "For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner."
+msgstr ""
+
 msgid "For additional information, review your group membership: %{link_to} or contact your group owner."
 msgstr ""
 
@@ -53886,6 +53892,15 @@ msgstr ""
 msgid "Your membership in %{group} no longer expires."
 msgstr ""
 
+msgid "Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}."
+msgstr ""
+
+msgid "Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}."
+msgstr ""
+
+msgid "Your membership will expire in %{days_to_expire} days"
+msgstr ""
+
 msgid "Your name"
 msgstr ""
 
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 629dfdaf55e8f4bcda176236b2737c8f19f7f7ef..976fe214c95fe85eb5c16897d90a561a33c9b254 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -2056,6 +2056,68 @@ def invite_to_group(group, inviter:, user: nil, tasks_to_be_done: [])
       end
     end
 
+    describe 'membership about to expire' do
+      context "with group membership" do
+        let_it_be(:group_member) { create(:group_member, source: group, expires_at: 7.days.from_now) }
+
+        subject { described_class.member_about_to_expire_email("Namespace", group_member.id) }
+
+        it_behaves_like 'an email sent from GitLab'
+        it_behaves_like 'it should not have Gmail Actions links'
+        it_behaves_like 'a user cannot unsubscribe through footer link'
+        it_behaves_like 'appearance header and footer enabled'
+        it_behaves_like 'appearance header and footer not enabled'
+
+        it 'contains all the useful information' do
+          is_expected.to deliver_to group_member.user.email
+          is_expected.to have_subject "Your membership will expire in 7 days"
+          is_expected.to have_body_text "group will expire in 7 days."
+          is_expected.to have_body_text group_url(group)
+          is_expected.to have_body_text group_group_members_url(group)
+        end
+      end
+
+      context "with project membership" do
+        let_it_be(:project_member) { create(:project_member, source: project, expires_at: 7.days.from_now) }
+
+        subject { described_class.member_about_to_expire_email('Project', project_member.id) }
+
+        it_behaves_like 'an email sent from GitLab'
+        it_behaves_like 'it should not have Gmail Actions links'
+        it_behaves_like 'a user cannot unsubscribe through footer link'
+        it_behaves_like 'appearance header and footer enabled'
+        it_behaves_like 'appearance header and footer not enabled'
+
+        it 'contains all the useful information' do
+          is_expected.to deliver_to project_member.user.email
+          is_expected.to have_subject "Your membership will expire in 7 days"
+          is_expected.to have_body_text "project will expire in 7 days."
+          is_expected.to have_body_text project_url(project)
+          is_expected.to have_body_text project_project_members_url(project)
+        end
+      end
+
+      context "with expired membership" do
+        let_it_be(:project_member) { create(:project_member, source: project, expires_at: Date.today) }
+
+        subject { described_class.member_about_to_expire_email('Project', project_member.id) }
+
+        it 'not deliver expiry email' do
+          should_not_email_anyone
+        end
+      end
+
+      context "with expiry notified membership" do
+        let_it_be(:project_member) { create(:project_member, source: project, expires_at: 7.days.from_now, expiry_notified_at: Date.today) }
+
+        subject { described_class.member_about_to_expire_email('Project', project_member.id) }
+
+        it 'not deliver expiry email' do
+          should_not_email_anyone
+        end
+      end
+    end
+
     describe 'admin notification' do
       let(:example_site_path) { root_path }
       let(:user) { create(:user) }
diff --git a/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb b/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..524354ecc9af5110328255c7bc21c590c0a76746
--- /dev/null
+++ b/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddIndexForMemberExpiringQuery, :migration, feature_category: :groups_and_projects do
+  let(:index_name) { 'index_members_on_expiring_at_access_level_id' }
+
+  it 'correctly migrates up and down' do
+    expect(subject).not_to be_index_exists_by_name(:members, index_name)
+
+    migrate!
+
+    expect(subject).to be_index_exists_by_name(:members, index_name)
+  end
+end
diff --git a/spec/migrations/add_expiry_notified_at_to_member_spec.rb b/spec/migrations/add_expiry_notified_at_to_member_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..30eaf06529e795491b92cd500dbb612bac5d41a5
--- /dev/null
+++ b/spec/migrations/add_expiry_notified_at_to_member_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddExpiryNotifiedAtToMember, feature_category: :system_access do
+  let(:members) { table(:members) }
+
+  it 'correctly migrates up and down' do
+    reversible_migration do |migration|
+      migration.before -> {
+        expect(members.column_names).not_to include('expiry_notified_at')
+      }
+
+      migration.after -> {
+        members.reset_column_information
+        expect(members.column_names).to include('expiry_notified_at')
+      }
+    end
+  end
+end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index d21edea9751d97eb7d24de3da8dd1bc9471dd91f..f8aaae3edada303f0f658ca43cdf1bca063a67cc 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -351,6 +351,19 @@
       it { is_expected.to include(expiring_tomorrow, not_expiring) }
     end
 
+    describe '.expiring_and_not_notified' do
+      let_it_be(:expiring_in_5_days) { create(:group_member, expires_at: 5.days.from_now) }
+      let_it_be(:expiring_in_5_days_with_notified) { create(:group_member, expires_at: 5.days.from_now, expiry_notified_at: Date.today) }
+      let_it_be(:expiring_in_7_days) { create(:group_member, expires_at: 7.days.from_now) }
+      let_it_be(:expiring_in_10_days) { create(:group_member, expires_at: 10.days.from_now) }
+      let_it_be(:not_expiring) { create(:group_member) }
+
+      subject { described_class.expiring_and_not_notified(7.days.from_now.to_date) }
+
+      it { is_expected.not_to include(expiring_in_5_days_with_notified, expiring_in_10_days, not_expiring) }
+      it { is_expected.to include(expiring_in_5_days, expiring_in_7_days) }
+    end
+
     describe '.created_today' do
       let_it_be(:now) { Time.current }
       let_it_be(:created_today) { create(:group_member, created_at: now.beginning_of_day) }
diff --git a/spec/services/members/update_service_spec.rb b/spec/services/members/update_service_spec.rb
index 1c4b1abcfdb67d0232138731b7e6e236d41e9c97..3860543a85e0d7b7cdce24c8df09b906e6d29fd4 100644
--- a/spec/services/members/update_service_spec.rb
+++ b/spec/services/members/update_service_spec.rb
@@ -219,6 +219,25 @@
         end
       end
     end
+
+    context 'when project members expiration date is updated with expiry_notified_at' do
+      let_it_be(:params) { { expires_at: 20.days.from_now } }
+
+      before do
+        group_project.group.add_owner(current_user)
+        members.each do |member|
+          member.update!(expiry_notified_at: Date.today)
+        end
+      end
+
+      it "clear expiry_notified_at" do
+        subject
+
+        members.each do |member|
+          expect(member.reload.expiry_notified_at).to be_nil
+        end
+      end
+    end
   end
 
   shared_examples 'updating a group' do
@@ -250,6 +269,24 @@
         subject
       end
     end
+
+    context 'when group members expiration date is updated with expiry_notified_at' do
+      let_it_be(:params) { { expires_at: 20.days.from_now } }
+
+      before do
+        members.each do |member|
+          member.update!(expiry_notified_at: Date.today)
+        end
+      end
+
+      it "clear expiry_notified_at" do
+        subject
+
+        members.each do |member|
+          expect(member.reload.expiry_notified_at).to be_nil
+        end
+      end
+    end
   end
 
   subject { update_service.execute(members, permission: permission) }
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 1d1dd045a09ce30a063a6026c7ef70300b637406..d3cc367649fc89a269a1dc631a465ef57b4c001e 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -3380,6 +3380,27 @@ def commit_to_hash(commit)
       end
     end
 
+    describe '#member_about_to_expire' do
+      let_it_be(:group_member) { create(:group_member, expires_at: 7.days.from_now.to_date) }
+      let_it_be(:project_member) { create(:project_member, expires_at: 7.days.from_now.to_date) }
+
+      context "with group member" do
+        it 'emails the user that their group membership will be expired' do
+          notification.member_about_to_expire(group_member)
+
+          should_email(group_member.user)
+        end
+      end
+
+      context "with project member" do
+        it 'emails the user that their project membership will be expired' do
+          notification.member_about_to_expire(project_member)
+
+          should_email(project_member.user)
+        end
+      end
+    end
+
     def create_member!
       create(:project_member, user: added_user, project: project)
     end
diff --git a/spec/workers/members/expiring_email_notification_worker_spec.rb b/spec/workers/members/expiring_email_notification_worker_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..600a81b37b8e5865af76ad217fc939e75976dcb5
--- /dev/null
+++ b/spec/workers/members/expiring_email_notification_worker_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::ExpiringEmailNotificationWorker, type: :worker, feature_category: :system_access do
+  subject(:worker) { described_class.new }
+
+  let_it_be(:member) { create(:project_member, :guest, expires_at: 7.days.from_now.to_date) }
+  let_it_be(:notified_member) do
+    create(:project_member, :guest, expires_at: 7.days.from_now.to_date, expiry_notified_at: Date.today)
+  end
+
+  describe '#perform' do
+    context "with not notified member" do
+      it "notify member" do
+        expect_next_instance_of(NotificationService) do |notification_service|
+          expect(notification_service).to receive(:member_about_to_expire).with(member)
+        end
+
+        worker.perform(member.id)
+
+        expect(member.reload.expiry_notified_at).to be_present
+      end
+    end
+
+    context "with notified member" do
+      it "not notify member" do
+        expect_next_instance_of(NotificationService) do |notification_service|
+          expect(notification_service).not_to receive(:member_about_to_expire).with(notified_member)
+        end
+
+        worker.perform(notified_member.id)
+      end
+    end
+
+    context "when feature member_expiring_email_notification is disabled" do
+      before do
+        stub_feature_flags(member_expiring_email_notification: false)
+      end
+
+      it "not notify member" do
+        expect_next_instance_of(NotificationService) do |notification_service|
+          expect(notification_service).not_to receive(:member_about_to_expire).with(member)
+        end
+
+        worker.perform(member.id)
+      end
+    end
+  end
+end
diff --git a/spec/workers/members/expiring_worker_spec.rb b/spec/workers/members/expiring_worker_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3f46548dbb3adde4ab6397d5271ff2eacc4b65be
--- /dev/null
+++ b/spec/workers/members/expiring_worker_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::ExpiringWorker, type: :worker, feature_category: :system_access do
+  subject(:worker) { described_class.new }
+
+  describe '#perform' do
+    let_it_be(:expiring_7_days_project_member) { create(:project_member, :guest, expires_at: 7.days.from_now) }
+    let_it_be(:expiring_7_days_group_member) { create(:group_member, :guest, expires_at: 7.days.from_now) }
+    let_it_be(:expiring_10_days_project_member) { create(:project_member, :guest, expires_at: 10.days.from_now) }
+    let_it_be(:expiring_5_days_project_member) { create(:project_member, :guest, expires_at: 5.days.from_now) }
+    let_it_be(:expiring_7_days_blocked_project_member) do
+      create(:project_member, :guest, :blocked, expires_at: 7.days.from_now)
+    end
+
+    let(:notifiy_worker) { Members::ExpiringEmailNotificationWorker }
+
+    it "notifies only active users with membership expiring in less than 7 days" do
+      expect(notifiy_worker).to receive(:perform_async).with(expiring_7_days_project_member.id)
+      expect(notifiy_worker).to receive(:perform_async).with(expiring_7_days_group_member.id)
+      expect(notifiy_worker).to receive(:perform_async).with(expiring_5_days_project_member.id)
+
+      worker.perform
+    end
+  end
+end