diff --git a/app/experiments/build_ios_app_guide_email_experiment.rb b/app/experiments/build_ios_app_guide_email_experiment.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d334a6a30d9b0d5cb0613851ed57f81fe052c35a
--- /dev/null
+++ b/app/experiments/build_ios_app_guide_email_experiment.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class BuildIosAppGuideEmailExperiment < ApplicationExperiment
+  control { false }
+  candidate { true }
+end
diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb
index 317e154535018d6a9de5d7bfe781cde0f16ea025..1b46d4841b0c0f4df4ac107351c745d903b33ac4 100644
--- a/app/mailers/emails/in_product_marketing.rb
+++ b/app/mailers/emails/in_product_marketing.rb
@@ -21,6 +21,12 @@ def in_product_marketing_email(recipient_id, group_id, track, series)
       mail_to(to: email, subject: @message.subject_line)
     end
 
+    def build_ios_app_guide_email(recipient_email)
+      @message = ::Gitlab::Email::Message::BuildIosAppGuide.new
+
+      mail_to(to: recipient_email, subject: @message.subject_line)
+    end
+
     private
 
     def mail_to(to:, subject:)
diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb
index f2f1d18339e886923a24177f5abe2370d407c8ee..82c2e336a09516d29234bbaec0d80fcb5f8809ff 100644
--- a/app/models/users/in_product_marketing_email.rb
+++ b/app/models/users/in_product_marketing_email.rb
@@ -4,15 +4,28 @@ module Users
   class InProductMarketingEmail < ApplicationRecord
     include BulkInsertSafe
 
+    BUILD_IOS_APP_GUIDE = 'build_ios_app_guide'
+    CAMPAIGNS = [BUILD_IOS_APP_GUIDE].freeze
+
     belongs_to :user
 
     validates :user, presence: true
-    validates :track, presence: true
-    validates :series, presence: true
+
+    validates :track, :series, presence: true, if: -> { campaign.blank? }
+    validates :campaign, presence: true, if: -> { track.blank? && series.blank? }
+    validates :campaign, inclusion: { in: CAMPAIGNS }, allow_nil: true
+
     validates :user_id, uniqueness: {
       scope: [:track, :series],
-      message: 'has already been sent'
-    }
+      message: 'track series email has already been sent'
+    }, if: -> { track.present? }
+
+    validates :user_id, uniqueness: {
+      scope: :campaign,
+      message: 'campaign email has already been sent'
+    }, if: -> { campaign.present? }
+
+    validate :campaign_or_track_series
 
     enum track: {
       create: 0,
@@ -31,23 +44,47 @@ class InProductMarketingEmail < ApplicationRecord
     INACTIVE_TRACK_NAMES = %w(invite_team).freeze
     ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES)
 
+    scope :for_user_with_track_and_series, -> (user, track, series) do
+      where(user: user, track: track, series: series)
+    end
+
     scope :without_track_and_series, -> (track, series) do
-      users = User.arel_table
-      product_emails = arel_table
+      join_condition = for_user.and(for_track_and_series(track, series))
+      users_without_records(join_condition)
+    end
+
+    scope :without_campaign, -> (campaign) do
+      join_condition = for_user.and(for_campaign(campaign))
+      users_without_records(join_condition)
+    end
 
-      join_condition = users[:id].eq(product_emails[:user_id])
-        .and(product_emails[:track]).eq(ACTIVE_TRACKS[track])
-        .and(product_emails[:series]).eq(series)
+    def self.users_table
+      User.arel_table
+    end
 
-      arel_join = users.join(product_emails, Arel::Nodes::OuterJoin).on(join_condition)
+    def self.distinct_users_sql
+      name = users_table.table_name
+      Arel.sql("DISTINCT ON(#{name}.id) #{name}.*")
+    end
 
+    def self.users_without_records(condition)
+      arel_join = users_table.join(arel_table, Arel::Nodes::OuterJoin).on(condition)
       joins(arel_join.join_sources)
         .where(in_product_marketing_emails: { id: nil })
-        .select(Arel.sql("DISTINCT ON(#{users.table_name}.id) #{users.table_name}.*"))
+        .select(distinct_users_sql)
     end
 
-    scope :for_user_with_track_and_series, -> (user, track, series) do
-      where(user: user, track: track, series: series)
+    def self.for_user
+      arel_table[:user_id].eq(users_table[:id])
+    end
+
+    def self.for_campaign(campaign)
+      arel_table[:campaign].eq(campaign)
+    end
+
+    def self.for_track_and_series(track, series)
+      arel_table[:track].eq(ACTIVE_TRACKS[track])
+        .and(arel_table[:series]).eq(series)
     end
 
     def self.save_cta_click(user, track, series)
@@ -55,5 +92,13 @@ def self.save_cta_click(user, track, series)
 
       email.update(cta_clicked_at: Time.zone.now) if email && email.cta_clicked_at.blank?
     end
+
+    private
+
+    def campaign_or_track_series
+      if campaign.present? && (track.present? || series.present?)
+        errors.add(:campaign, 'should be a campaign or a track and series but not both')
+      end
+    end
   end
 end
diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb
index e42c3498c21439feb7bf6748a4e62f45299db60a..414f253deb865e0342c3be975d73de422bfffb80 100644
--- a/app/services/namespaces/in_product_marketing_emails_service.rb
+++ b/app/services/namespaces/in_product_marketing_emails_service.rb
@@ -61,7 +61,7 @@ def self.send_for_all_tracks_and_intervals
     def initialize(track, interval)
       @track = track
       @interval = interval
-      @sent_email_records = InProductMarketingEmailRecords.new
+      @sent_email_records = ::Users::InProductMarketingEmailRecords.new
     end
 
     def execute
@@ -86,7 +86,7 @@ def send_email_for_group(group)
       users_for_group(group).each do |user|
         if can_perform_action?(user, group)
           send_email(user, group)
-          sent_email_records.add(user, track, series)
+          sent_email_records.add(user, track: track, series: series)
         end
       end
 
diff --git a/app/services/projects/in_product_marketing_campaign_emails_service.rb b/app/services/projects/in_product_marketing_campaign_emails_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..249a2d89fc12a5a2cb9e1e3f378a773b862131cc
--- /dev/null
+++ b/app/services/projects/in_product_marketing_campaign_emails_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Projects
+  class InProductMarketingCampaignEmailsService
+    include Gitlab::Experiment::Dsl
+
+    def initialize(project, campaign)
+      @project = project
+      @campaign = campaign
+      @sent_email_records = ::Users::InProductMarketingEmailRecords.new
+    end
+
+    def execute
+      send_emails
+    end
+
+    private
+
+    attr_reader :project, :campaign, :sent_email_records
+
+    def send_emails
+      project_users.each do |user|
+        send_email(user)
+      end
+
+      sent_email_records.save!
+    end
+
+    # rubocop: disable CodeReuse/ActiveRecord
+    def project_users
+      @project_users ||= project.users
+        .where(email_opted_in: true)
+        .merge(Users::InProductMarketingEmail.without_campaign(campaign))
+    end
+    # rubocop: enable CodeReuse/ActiveRecord
+
+    def project_users_max_access_levels
+      ids = project_users.map(&:id)
+      @project_users_max_access_levels ||= project.team.max_member_access_for_user_ids(ids)
+    end
+
+    def send_email(user)
+      return unless user.can?(:receive_notifications)
+      return unless target_user?(user)
+
+      Notify.build_ios_app_guide_email(user.notification_email_or_default).deliver_later
+
+      sent_email_records.add(user, campaign: campaign)
+      experiment(:build_ios_app_guide_email, project: project).track(:email_sent)
+    end
+
+    def target_user?(user)
+      max_access_level = project_users_max_access_levels[user.id]
+      max_access_level >= Gitlab::Access::DEVELOPER
+    end
+  end
+end
diff --git a/app/services/projects/record_target_platforms_service.rb b/app/services/projects/record_target_platforms_service.rb
index 224e16f53b351397fd1713a3d3483992f1c2ddb8..b2d01ce657a6854a423c4218c92f4715264bbbd1 100644
--- a/app/services/projects/record_target_platforms_service.rb
+++ b/app/services/projects/record_target_platforms_service.rb
@@ -19,11 +19,27 @@ def target_platforms
     def record_target_platforms
       return unless target_platforms.present?
 
-      setting = ::ProjectSetting.find_or_initialize_by(project: project) # rubocop:disable CodeReuse/ActiveRecord
-      setting.target_platforms = target_platforms
-      setting.save
+      project_setting.target_platforms = target_platforms
+      project_setting.save
 
-      setting.target_platforms
+      send_build_ios_app_guide_email
+
+      project_setting.target_platforms
+    end
+
+    def project_setting
+      @project_setting ||= ::ProjectSetting.find_or_initialize_by(project: project) # rubocop:disable CodeReuse/ActiveRecord
+    end
+
+    def experiment_candidate?
+      experiment(:build_ios_app_guide_email, project: project).run
+    end
+
+    def send_build_ios_app_guide_email
+      return unless experiment_candidate?
+
+      campaign = Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE
+      Projects::InProductMarketingCampaignEmailsService.new(project, campaign).execute
     end
   end
 end
diff --git a/app/services/namespaces/in_product_marketing_email_records.rb b/app/services/users/in_product_marketing_email_records.rb
similarity index 82%
rename from app/services/namespaces/in_product_marketing_email_records.rb
rename to app/services/users/in_product_marketing_email_records.rb
index 1237a05ea133abf202bbfb7615f6582feab2d279..94dbd8094966a85c44ab2761d65ab377fce7f35a 100644
--- a/app/services/namespaces/in_product_marketing_email_records.rb
+++ b/app/services/users/in_product_marketing_email_records.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-module Namespaces
+module Users
   class InProductMarketingEmailRecords
     attr_reader :records
 
@@ -13,9 +13,10 @@ def save!
       @records = []
     end
 
-    def add(user, track, series)
+    def add(user, campaign: nil, track: nil, series: nil)
       @records << Users::InProductMarketingEmail.new(
         user: user,
+        campaign: campaign,
         track: track,
         series: series,
         created_at: Time.zone.now,
diff --git a/app/views/notify/build_ios_app_guide_email.html.haml b/app/views/notify/build_ios_app_guide_email.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..e9f23d3c0f9a37975b767e6e1de1b400efdda53c
--- /dev/null
+++ b/app/views/notify/build_ios_app_guide_email.html.haml
@@ -0,0 +1,13 @@
+%tr
+  %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" }
+    = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' })
+    %h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" }
+      = @message.title
+%tr
+  %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
+    %p{ style: "margin: 0 0 20px 0;" }
+      = @message.body_line1.html_safe
+%tr
+  %td{ align: "center", style: "padding: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
+    .cta_link.cta_link_primary= @message.cta_link
+    .cta_link.cta_link_secondary= @message.cta2_link
diff --git a/app/views/notify/build_ios_app_guide_email.text.erb b/app/views/notify/build_ios_app_guide_email.text.erb
new file mode 100644
index 0000000000000000000000000000000000000000..59757b7c1b02ee64cda19b38c898f25d33141914
--- /dev/null
+++ b/app/views/notify/build_ios_app_guide_email.text.erb
@@ -0,0 +1,13 @@
+<%= @message.title %>
+
+<%= @message.body_line1 %>
+
+<%= @message.cta_link %>
+
+<%= @message.cta2_link %>
+
+<%= @message.footer_links %>
+
+<%= @message.address %>
+
+<%= @message.unsubscribe %>
diff --git a/config/feature_flags/experiment/build_ios_app_guide_email.yml b/config/feature_flags/experiment/build_ios_app_guide_email.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4e90b036063c6f98a33e907883c828998ba55da3
--- /dev/null
+++ b/config/feature_flags/experiment/build_ios_app_guide_email.yml
@@ -0,0 +1,8 @@
+---
+name: build_ios_app_guide_email
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83817
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/357803
+milestone: '15.0'
+type: experiment
+group: group::activation
+default_enabled: false
diff --git a/db/migrate/20220401071609_add_campaign_to_in_product_marketing_email.rb b/db/migrate/20220401071609_add_campaign_to_in_product_marketing_email.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f8d027cfd2e4d0b8633d959c4ceaae0ee330e812
--- /dev/null
+++ b/db/migrate/20220401071609_add_campaign_to_in_product_marketing_email.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class AddCampaignToInProductMarketingEmail < Gitlab::Database::Migration[1.0]
+  disable_ddl_transaction!
+
+  TARGET_TABLE = :in_product_marketing_emails
+  UNIQUE_INDEX_NAME = :index_in_product_marketing_emails_on_user_campaign
+  CONSTRAINT_NAME = :in_product_marketing_emails_track_and_series_or_campaign
+  TRACK_AND_SERIES_NOT_NULL_CONSTRAINT = 'track IS NOT NULL AND series IS NOT NULL AND campaign IS NULL'
+  CAMPAIGN_NOT_NULL_CONSTRAINT = 'track IS NULL AND series IS NULL AND campaign IS NOT NULL'
+
+  def up
+    change_column_null TARGET_TABLE, :track, true
+    change_column_null TARGET_TABLE, :series, true
+
+    # rubocop:disable Migration/AddLimitToTextColumns
+    # limit is added in 20220420034519_add_text_limit_to_in_product_marketing_email_campaign.rb
+    add_column :in_product_marketing_emails, :campaign, :text, if_not_exists: true
+    # rubocop:enable Migration/AddLimitToTextColumns
+    add_concurrent_index TARGET_TABLE, [:user_id, :campaign], unique: true, name: UNIQUE_INDEX_NAME
+    add_check_constraint TARGET_TABLE,
+      "(#{TRACK_AND_SERIES_NOT_NULL_CONSTRAINT}) OR (#{CAMPAIGN_NOT_NULL_CONSTRAINT})",
+      CONSTRAINT_NAME
+  end
+
+  def down
+    remove_check_constraint TARGET_TABLE, CONSTRAINT_NAME
+    remove_concurrent_index TARGET_TABLE, [:user_id, :campaign], name: UNIQUE_INDEX_NAME
+    remove_column :in_product_marketing_emails, :campaign, if_exists: true
+
+    # Records that previously had a value for campaign column will have NULL
+    # values for track and series columns so we can't reverse
+    # change_column_null.
+  end
+end
diff --git a/db/migrate/20220420034519_add_text_limit_to_in_product_marketing_email_campaign.rb b/db/migrate/20220420034519_add_text_limit_to_in_product_marketing_email_campaign.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c590111da21df15bf5c4675588e88591f12126cd
--- /dev/null
+++ b/db/migrate/20220420034519_add_text_limit_to_in_product_marketing_email_campaign.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AddTextLimitToInProductMarketingEmailCampaign < Gitlab::Database::Migration[2.0]
+  disable_ddl_transaction!
+
+  def up
+    add_text_limit :in_product_marketing_emails, :campaign, 255
+  end
+
+  def down
+    remove_text_limit :in_product_marketing_emails, :campaign
+  end
+end
diff --git a/db/schema_migrations/20220401071609 b/db/schema_migrations/20220401071609
new file mode 100644
index 0000000000000000000000000000000000000000..1fa11a31867027f7eeadf5ec2d3202b0482c287f
--- /dev/null
+++ b/db/schema_migrations/20220401071609
@@ -0,0 +1 @@
+fa1651c066191279fe922b311be3e112b87648c52b1af7a81d7b73ebfe2f7177
\ No newline at end of file
diff --git a/db/schema_migrations/20220420034519 b/db/schema_migrations/20220420034519
new file mode 100644
index 0000000000000000000000000000000000000000..0b46a4df4a97136108ba7323951b289703552b1c
--- /dev/null
+++ b/db/schema_migrations/20220420034519
@@ -0,0 +1 @@
+8ce9e197aa590d01755541a9f1c53d6835a9d4ae389e011c5050778d19e80f00
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index f5d7e495943edde1f996a1b520957e71ecb10c80..fa604a624ca5f40f31c12c79d5459ec1a2662004 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -15876,10 +15876,13 @@ CREATE TABLE in_product_marketing_emails (
     id bigint NOT NULL,
     user_id bigint NOT NULL,
     cta_clicked_at timestamp with time zone,
-    track smallint NOT NULL,
-    series smallint NOT NULL,
+    track smallint,
+    series smallint,
     created_at timestamp with time zone NOT NULL,
-    updated_at timestamp with time zone NOT NULL
+    updated_at timestamp with time zone NOT NULL,
+    campaign text,
+    CONSTRAINT check_9d8b29f74f CHECK ((char_length(campaign) <= 255)),
+    CONSTRAINT in_product_marketing_emails_track_and_series_or_campaign CHECK ((((track IS NOT NULL) AND (series IS NOT NULL) AND (campaign IS NULL)) OR ((track IS NULL) AND (series IS NULL) AND (campaign IS NOT NULL))))
 );
 
 CREATE SEQUENCE in_product_marketing_emails_id_seq
@@ -27882,6 +27885,8 @@ CREATE INDEX index_imported_projects_on_import_type_creator_id_created_at ON pro
 
 CREATE INDEX index_imported_projects_on_import_type_id ON projects USING btree (import_type, id) WHERE (import_type IS NOT NULL);
 
+CREATE UNIQUE INDEX index_in_product_marketing_emails_on_user_campaign ON in_product_marketing_emails USING btree (user_id, campaign);
+
 CREATE INDEX index_in_product_marketing_emails_on_user_id ON in_product_marketing_emails USING btree (user_id);
 
 CREATE UNIQUE INDEX index_in_product_marketing_emails_on_user_track_series ON in_product_marketing_emails USING btree (user_id, track, series);
diff --git a/lib/gitlab/email/message/build_ios_app_guide.rb b/lib/gitlab/email/message/build_ios_app_guide.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4acf558a6a25967fb599949415ac440a1cd88f7f
--- /dev/null
+++ b/lib/gitlab/email/message/build_ios_app_guide.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Email
+    module Message
+      class BuildIosAppGuide
+        include Gitlab::Email::Message::InProductMarketing::Helper
+        include Gitlab::Routing
+
+        attr_accessor :format
+
+        def initialize(format: :html)
+          @format = format
+        end
+
+        def subject_line
+          s_('InProductMarketing|Get set up to build for iOS')
+        end
+
+        def title
+          s_("InProductMarketing|Building for iOS? We've got you covered.")
+        end
+
+        def body_line1
+          s_(
+            'InProductMarketing|Want to get your iOS app up and running, including publishing all the way to ' \
+            'TestFlight? Follow our guide to set up GitLab and fastlane to publish iOS apps to the App Store.'
+          )
+        end
+
+        def cta_text
+          s_('InProductMarketing|Learn how to build for iOS')
+        end
+
+        def cta_link
+          action_link(cta_text, 'https://about.gitlab.com/blog/2019/03/06/ios-publishing-with-gitlab-and-fastlane/')
+        end
+
+        def cta2_text
+          s_('InProductMarketing|Watch iOS building in action.')
+        end
+
+        def cta2_link
+          action_link(cta2_text, 'https://www.youtube.com/watch?v=325FyJt7ZG8')
+        end
+
+        def logo_path
+          'mailers/in_product_marketing/create-0.png'
+        end
+
+        def unsubscribe
+          unsubscribe_message
+        end
+      end
+    end
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 170df6bc05aff8f313ad14c6157bbe20cb50bc32..fc54eabf2743bb9712812e8c61490cc37d0f30d4 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -19385,6 +19385,9 @@ msgstr ""
 msgid "InProductMarketing|Break down silos to coordinate seamlessly across development, operations, and security with a consistent experience across the development lifecycle."
 msgstr ""
 
+msgid "InProductMarketing|Building for iOS? We've got you covered."
+msgstr ""
+
 msgid "InProductMarketing|Burn up/down charts"
 msgstr ""
 
@@ -19505,6 +19508,9 @@ msgstr ""
 msgid "InProductMarketing|Get our import guides"
 msgstr ""
 
+msgid "InProductMarketing|Get set up to build for iOS"
+msgstr ""
+
 msgid "InProductMarketing|Get started today"
 msgstr ""
 
@@ -19616,6 +19622,9 @@ msgstr ""
 msgid "InProductMarketing|Launch GitLab CI/CD in 20 minutes or less"
 msgstr ""
 
+msgid "InProductMarketing|Learn how to build for iOS"
+msgstr ""
+
 msgid "InProductMarketing|Lower cost of development"
 msgstr ""
 
@@ -19799,9 +19808,15 @@ msgstr ""
 msgid "InProductMarketing|Visualize your epics and milestones in a timeline."
 msgstr ""
 
+msgid "InProductMarketing|Want to get your iOS app up and running, including publishing all the way to TestFlight? Follow our guide to set up GitLab and fastlane to publish iOS apps to the App Store."
+msgstr ""
+
 msgid "InProductMarketing|Want to host GitLab on your servers?"
 msgstr ""
 
+msgid "InProductMarketing|Watch iOS building in action."
+msgstr ""
+
 msgid "InProductMarketing|We know a thing or two about efficiency and we don't want to keep that to ourselves. Sign up for a free trial of GitLab Ultimate and your teams will be on it from day one."
 msgstr ""
 
diff --git a/spec/factories/users/in_product_marketing_email.rb b/spec/factories/users/in_product_marketing_email.rb
index c86c469ff31bd90757de5b64e132badf64cd5e82..42309319bf3dde020b5a5f247343dc5b32540a5a 100644
--- a/spec/factories/users/in_product_marketing_email.rb
+++ b/spec/factories/users/in_product_marketing_email.rb
@@ -6,5 +6,11 @@
 
     track { 'create' }
     series { 0 }
+
+    trait :campaign do
+      track { nil }
+      series { nil }
+      campaign { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
+    end
   end
 end
diff --git a/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb b/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3089f9552521ce8cfd43013210f9d9bba6fb70b0
--- /dev/null
+++ b/spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Email::Message::BuildIosAppGuide do
+  subject(:message) { described_class.new }
+
+  before do
+    allow(Gitlab).to receive(:com?) { true }
+  end
+
+  it 'contains the correct message', :aggregate_failures do
+    expect(message.subject_line).to eq 'Get set up to build for iOS'
+    expect(message.title).to eq "Building for iOS? We've got you covered."
+    expect(message.body_line1).to eq "Want to get your iOS app up and running, including " \
+      "publishing all the way to TestFlight? Follow our guide to set up GitLab and fastlane to publish iOS apps to " \
+      "the App Store."
+    expect(message.cta_text).to eq 'Learn how to build for iOS'
+    expect(message.cta2_text).to eq 'Watch iOS building in action.'
+    expect(message.logo_path).to eq 'mailers/in_product_marketing/create-0.png'
+    expect(message.unsubscribe).to include('%tag_unsubscribe_url%')
+  end
+end
diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb
index e62719f428367a00ccc004c8d2783d557d933e4c..7f3896a3d516c7cc699851297d9a57491a9a5c12 100644
--- a/spec/mailers/emails/in_product_marketing_spec.rb
+++ b/spec/mailers/emails/in_product_marketing_spec.rb
@@ -103,4 +103,28 @@
       end
     end
   end
+
+  describe '#build_ios_app_guide_email' do
+    subject { Notify.build_ios_app_guide_email(user.notification_email_or_default) }
+
+    it 'sends to the right user' do
+      expect(subject).to deliver_to(user.notification_email_or_default)
+    end
+
+    it 'has the correct subject and content' do
+      message = Gitlab::Email::Message::BuildIosAppGuide.new
+      cta_url = 'https://about.gitlab.com/blog/2019/03/06/ios-publishing-with-gitlab-and-fastlane/'
+      cta2_url = 'https://www.youtube.com/watch?v=325FyJt7ZG8'
+
+      aggregate_failures do
+        is_expected.to have_subject(message.subject_line)
+        is_expected.to have_body_text(message.title)
+        is_expected.to have_body_text(message.body_line1)
+        is_expected.to have_body_text(CGI.unescapeHTML(message.cta_link))
+        is_expected.to have_body_text(CGI.unescapeHTML(message.cta2_link))
+        is_expected.to have_body_text(cta_url)
+        is_expected.to have_body_text(cta2_url)
+      end
+    end
+  end
 end
diff --git a/spec/models/users/in_product_marketing_email_spec.rb b/spec/models/users/in_product_marketing_email_spec.rb
index ca03c3e645dc9f5293a2c5698e8258251c0cd4b6..7796b54babceb000d6edae0070e36773093e480f 100644
--- a/spec/models/users/in_product_marketing_email_spec.rb
+++ b/spec/models/users/in_product_marketing_email_spec.rb
@@ -14,9 +14,35 @@
     subject { build(:in_product_marketing_email) }
 
     it { is_expected.to validate_presence_of(:user) }
-    it { is_expected.to validate_presence_of(:track) }
-    it { is_expected.to validate_presence_of(:series) }
-    it { is_expected.to validate_uniqueness_of(:user_id).scoped_to([:track, :series]).with_message('has already been sent') }
+
+    context 'for a track+series email' do
+      it { is_expected.to validate_presence_of(:track) }
+      it { is_expected.to validate_presence_of(:series) }
+      it {
+        is_expected.to validate_uniqueness_of(:user_id)
+          .scoped_to([:track, :series]).with_message('track series email has already been sent')
+      }
+    end
+
+    context 'for a campaign email' do
+      subject { build(:in_product_marketing_email, :campaign) }
+
+      it { is_expected.to validate_presence_of(:campaign) }
+      it { is_expected.not_to validate_presence_of(:track) }
+      it { is_expected.not_to validate_presence_of(:series) }
+      it {
+        is_expected.to validate_uniqueness_of(:user_id)
+          .scoped_to(:campaign).with_message('campaign email has already been sent')
+      }
+      it { is_expected.to validate_inclusion_of(:campaign).in_array(described_class::CAMPAIGNS) }
+    end
+
+    context 'when mixing campaign and track+series' do
+      it 'is not valid' do
+        expect(build(:in_product_marketing_email, :campaign, track: :create)).not_to be_valid
+        expect(build(:in_product_marketing_email, :campaign, series: 0)).not_to be_valid
+      end
+    end
   end
 
   describe '.without_track_and_series' do
@@ -58,6 +84,27 @@
     end
   end
 
+  describe '.without_campaign' do
+    let_it_be(:user) { create(:user) }
+    let_it_be(:other_user) { create(:user) }
+
+    let(:campaign) { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
+
+    subject(:without_campaign) { User.merge(described_class.without_campaign(campaign)) }
+
+    context 'when record for campaign already exists' do
+      before do
+        create(:in_product_marketing_email, :campaign, campaign: campaign, user: user)
+      end
+
+      it { is_expected.to match_array [other_user] }
+    end
+
+    context 'when record for campaign does not exist' do
+      it { is_expected.to match_array [user, other_user] }
+    end
+  end
+
   describe '.for_user_with_track_and_series' do
     let_it_be(:user) { create(:user) }
     let_it_be(:in_product_marketing_email) { create(:in_product_marketing_email, series: 0, track: 0, user: user) }
diff --git a/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb b/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4c51c8a4ac8331f675bf4f8337872b4796c1e936
--- /dev/null
+++ b/spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::InProductMarketingCampaignEmailsService do
+  describe '#execute' do
+    let(:user) { create(:user, email_opted_in: true) }
+    let(:project) { create(:project) }
+    let(:campaign) { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
+
+    subject(:execute) do
+      described_class.new(project, campaign).execute
+    end
+
+    context 'users can receive marketing emails' do
+      let(:owner) { create(:user, email_opted_in: true) }
+      let(:maintainer) { create(:user, email_opted_in: true) }
+      let(:developer) { create(:user, email_opted_in: true) }
+
+      before do
+        project.add_owner(owner)
+        project.add_developer(developer)
+        project.add_maintainer(maintainer)
+      end
+
+      it 'sends the email to all project members with access_level >= Developer', :aggregate_failures do
+        double = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
+
+        [owner, maintainer, developer].each do |user|
+          email = user.notification_email_or_default
+
+          expect(Notify).to receive(:build_ios_app_guide_email).with(email) { double }
+          expect(double).to receive(:deliver_later)
+        end
+
+        execute
+      end
+
+      it 'records sent emails', :aggregate_failures do
+        expect { execute }.to change { Users::InProductMarketingEmail.count }.from(0).to(3)
+
+        [owner, maintainer, developer].each do |user|
+          expect(
+            Users::InProductMarketingEmail.where(
+              user: user,
+              campaign: campaign
+            )
+          ).to exist
+        end
+      end
+
+      it 'tracks experiment :email_sent event', :experiment do
+        expect(experiment(:build_ios_app_guide_email)).to track(:email_sent)
+          .on_next_instance
+          .with_context(project: project)
+
+        execute
+      end
+    end
+
+    shared_examples 'does nothing' do
+      it 'does not send the email' do
+        email = user.notification_email_or_default
+        expect(Notify).not_to receive(:build_ios_app_guide_email).with(email)
+        execute
+      end
+
+      it 'does not create a record of the sent email' do
+        expect { execute }.not_to change { Users::InProductMarketingEmail.count }
+      end
+    end
+
+    context "when user can't receive marketing emails" do
+      before do
+        project.add_developer(user)
+      end
+
+      context 'when user.can?(:receive_notifications) is false' do
+        it 'does not send the email' do
+          allow_next_found_instance_of(User) do |user|
+            allow(user).to receive(:can?).with(:receive_notifications) { false }
+
+            email = user.notification_email_or_default
+            expect(Notify).not_to receive(:build_ios_app_guide_email).with(email)
+
+            expect(
+              Users::InProductMarketingEmail.where(
+                user: user,
+                campaign: campaign
+              )
+            ).not_to exist
+          end
+
+          execute
+        end
+      end
+
+      context 'when user is not opted in to receive marketing emails' do
+        let(:user) { create(:user, email_opted_in: false) }
+
+        it_behaves_like 'does nothing'
+      end
+    end
+
+    context 'when campaign email has already been sent to the user' do
+      before do
+        project.add_developer(user)
+        create(:in_product_marketing_email, :campaign, user: user, campaign: campaign)
+      end
+
+      it_behaves_like 'does nothing'
+    end
+
+    context "when user is a reporter" do
+      before do
+        project.add_reporter(user)
+      end
+
+      it_behaves_like 'does nothing'
+    end
+
+    context "when user is a guest" do
+      before do
+        project.add_guest(user)
+      end
+
+      it_behaves_like 'does nothing'
+    end
+  end
+end
diff --git a/spec/services/projects/record_target_platforms_service_spec.rb b/spec/services/projects/record_target_platforms_service_spec.rb
index 85311f36428d7683b3b869d09079ba22a387374e..c504367557b22d93c1beb33f81644e0a4438fff4 100644
--- a/spec/services/projects/record_target_platforms_service_spec.rb
+++ b/spec/services/projects/record_target_platforms_service_spec.rb
@@ -8,6 +8,10 @@
   subject(:execute) { described_class.new(project).execute }
 
   context 'when project is an XCode project' do
+    def project_setting
+      ProjectSetting.find_by_project_id(project.id)
+    end
+
     before do
       double = instance_double(Projects::AppleTargetPlatformDetectorService, execute: [:ios, :osx])
       allow(Projects::AppleTargetPlatformDetectorService).to receive(:new) { double }
@@ -27,10 +31,6 @@
         create(:project_setting, project: project, target_platforms: saved_target_platforms)
       end
 
-      def project_setting
-        ProjectSetting.find_by_project_id(project.id)
-      end
-
       context 'when target platforms changed' do
         let(:saved_target_platforms) { %w(tvos) }
 
@@ -49,6 +49,52 @@ def project_setting
         end
       end
     end
+
+    describe 'Build iOS guide email experiment' do
+      shared_examples 'tracks experiment assignment event' do
+        it 'tracks the assignment event', :experiment do
+          expect(experiment(:build_ios_app_guide_email))
+            .to track(:assignment)
+            .with_context(project: project)
+            .on_next_instance
+
+          execute
+        end
+      end
+
+      context 'experiment candidate' do
+        before do
+          stub_experiments(build_ios_app_guide_email: :candidate)
+        end
+
+        it 'executes a Projects::InProductMarketingCampaignEmailsService' do
+          service_double = instance_double(Projects::InProductMarketingCampaignEmailsService, execute: true)
+
+          expect(Projects::InProductMarketingCampaignEmailsService)
+            .to receive(:new).with(project, Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
+            .and_return service_double
+          expect(service_double).to receive(:execute)
+
+          execute
+        end
+
+        it_behaves_like 'tracks experiment assignment event'
+      end
+
+      context 'experiment control' do
+        before do
+          stub_experiments(build_ios_app_guide_email: :control)
+        end
+
+        it 'does not execute a Projects::InProductMarketingCampaignEmailsService' do
+          expect(Projects::InProductMarketingCampaignEmailsService).not_to receive(:new)
+
+          execute
+        end
+
+        it_behaves_like 'tracks experiment assignment event'
+      end
+    end
   end
 
   context 'when project is not an XCode project' do
diff --git a/spec/services/namespaces/in_product_marketing_email_records_spec.rb b/spec/services/users/in_product_marketing_email_records_spec.rb
similarity index 57%
rename from spec/services/namespaces/in_product_marketing_email_records_spec.rb
rename to spec/services/users/in_product_marketing_email_records_spec.rb
index d80e20135d5f42d5cf9920db24e06299acdc1630..0b9400dcd12a9104f43e2b91175f8acbd2f30189 100644
--- a/spec/services/namespaces/in_product_marketing_email_records_spec.rb
+++ b/spec/services/users/in_product_marketing_email_records_spec.rb
@@ -2,7 +2,7 @@
 
 require 'spec_helper'
 
-RSpec.describe Namespaces::InProductMarketingEmailRecords do
+RSpec.describe Users::InProductMarketingEmailRecords do
   let_it_be(:user) { create :user }
 
   subject(:records) { described_class.new }
@@ -15,8 +15,9 @@
     before do
       allow(Users::InProductMarketingEmail).to receive(:bulk_insert!)
 
-      records.add(user, :team_short, 0)
-      records.add(user, :create, 1)
+      records.add(user, track: :team_short, series: 0)
+      records.add(user, track: :create, series: 1)
+      records.add(user, campaign: Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
     end
 
     it 'bulk inserts added records' do
@@ -31,24 +32,34 @@
   end
 
   describe '#add' do
-    it 'adds a Users::InProductMarketingEmail record to its records' do
+    it 'adds a Users::InProductMarketingEmail record to its records', :aggregate_failures do
       freeze_time do
-        records.add(user, :team_short, 0)
-        records.add(user, :create, 1)
+        records.add(user, track: :team_short, series: 0)
+        records.add(user, track: :create, series: 1)
+        records.add(user, campaign: Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
 
-        first, second = records.records
+        first, second, third = records.records
 
         expect(first).to be_a Users::InProductMarketingEmail
+        expect(first.campaign).to be_nil
         expect(first.track.to_sym).to eq :team_short
         expect(first.series).to eq 0
         expect(first.created_at).to eq Time.zone.now
         expect(first.updated_at).to eq Time.zone.now
 
         expect(second).to be_a Users::InProductMarketingEmail
+        expect(second.campaign).to be_nil
         expect(second.track.to_sym).to eq :create
         expect(second.series).to eq 1
         expect(second.created_at).to eq Time.zone.now
         expect(second.updated_at).to eq Time.zone.now
+
+        expect(third).to be_a Users::InProductMarketingEmail
+        expect(third.campaign).to eq Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE
+        expect(third.track).to be_nil
+        expect(third.series).to be_nil
+        expect(third.created_at).to eq Time.zone.now
+        expect(third.updated_at).to eq Time.zone.now
       end
     end
   end