gitlab-email_handler 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9959523f39476ee9ef18e9434e6b53f06cfee59ed10fc06ea3c58010fc96bfdb
4
- data.tar.gz: 3bf21681b7943e5071436edb0ebe92a11cdc727703f8b3f799a104270c0d0a69
3
+ metadata.gz: a5f1f3376c9afa7036243ffa5c2e9ebe5916b516aa0493126b8105a3d20b3835
4
+ data.tar.gz: 2efd5e7c9c09d88ace45cf77480fdebf8236956d8aaf616ce1413ff644c5757f
5
5
  SHA512:
6
- metadata.gz: 17f60936562182791d348d2e764c0a62d73d5feed7b6ec7a839f363126b34545c8c3942d1bb77ff1a8ef11dbd3852c36e453970232f5ff0193d0f5c8fa2c185a
7
- data.tar.gz: a6d1434a975d8242b6b87536ecf5b5208a6ed0d054b85ed13b8cb5b9a7bde95e7e00cb6d63b017e1b95062ce3aa0a262f7e888c9ac7921e416fd930f1ebc9eda
6
+ metadata.gz: 4c0f7eb203d86315769156dda59a9d20d6fa216a3524d62aacee97bc2408451b7b2d69994c8cbf4849cc9d154eba9a5f128dc8b1dae83a20b03917fcf7502765
7
+ data.tar.gz: 005f7845096ad4b55bbde87fa6bbe3e380bdc43ced7db791a69438c13b9598820ffed3225416c73cf93257adb23a688182efd17f28248d6cd755464b63652b9e
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'reply_key'
4
+
5
+ module Gitlab
6
+ module EmailHandler
7
+ # Helpers for recognising and normalising custom Service Desk email
8
+ # addresses (e.g. support@acme.com). These mirror the parsing in
9
+ # Gitlab::Email::ServiceDesk::CustomEmail but without any database access -
10
+ # whether an address is actually a registered custom email is confirmed by
11
+ # the Topology Service (see CellRouter).
12
+ module CustomEmail
13
+ VERIFICATION_SUBADDRESS = '+verify'
14
+
15
+ # Matches a reply address like "support+<reply_key>@acme.com". The full
16
+ # reply key spans everything between the first "+" and the "@"; we then
17
+ # validate that span against the reply-key format. We capture by position
18
+ # (not a named group) to avoid colliding with the named groups inside
19
+ # FULL_REPLY_KEY_REGEX.
20
+ FULL_REPLY_KEY_ANCHORED = /\A#{ReplyKey::FULL_REPLY_KEY_REGEX}\z/
21
+ REPLY_ADDRESS_REGEX = /\A(?<base>[^+]+)\+(?<full_key>[^@]+)@(?<domain>.+)\z/
22
+
23
+ # Loose email shape check. Custom emails are arbitrary addresses, so this
24
+ # only filters out obvious non-addresses before asking the Topology
25
+ # Service.
26
+ EMAIL_REGEX = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
27
+
28
+ module_function
29
+
30
+ # Returns the base custom email address for a candidate recipient, with
31
+ # any "+verify" or "+<reply_key>" sub-addressing removed, or nil if the
32
+ # value doesn't look like an email address.
33
+ #
34
+ # support@acme.com -> support@acme.com
35
+ # support+verify@acme.com -> support@acme.com
36
+ # support+<reply_key>@acme.com -> support@acme.com
37
+ def base_address(address)
38
+ value = address.to_s.strip
39
+ return unless EMAIL_REGEX.match?(value)
40
+
41
+ match = reply_address_match(value)
42
+ return "#{match[:base]}@#{match[:domain]}" if match
43
+
44
+ value.sub(VERIFICATION_SUBADDRESS, '')
45
+ end
46
+
47
+ # Extracts the reply key from a custom email reply address, or nil.
48
+ # support+<reply_key>@acme.com -> <reply_key>
49
+ def reply_key(address)
50
+ reply_address_match(address)&.[](:full_key)
51
+ end
52
+
53
+ # Matches a reply address and validates the embedded key against the
54
+ # reply-key format, returning the MatchData or nil. Centralising the
55
+ # match avoids running REPLY_ADDRESS_REGEX twice.
56
+ def reply_address_match(address)
57
+ match = REPLY_ADDRESS_REGEX.match(address.to_s)
58
+ return unless match
59
+ return unless FULL_REPLY_KEY_ANCHORED.match?(match[:full_key])
60
+
61
+ match
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'reply_key'
4
+ require_relative 'target'
5
+
6
+ module Gitlab
7
+ module EmailHandler
8
+ # Result of parsing an incoming email key. Carries the handler that matched
9
+ # and the captures relevant to that handler. The captures are handler
10
+ # specific (project_id, project_path, reply_key, namespace_id, etc.).
11
+ #
12
+ # `#target` returns the identified Target (the resource that owns the email),
13
+ # or nil when the email can't be identified (legacy reply keys without an
14
+ # encoded namespace):
15
+ #
16
+ # - Target.project_id - project id encoded directly in the key
17
+ # - Target.namespace_id - decoded offline from a partitioned reply key
18
+ # - Target.route - full project/group path (located by its top-level
19
+ # namespace)
20
+ # - Target.service_desk_project_key_address_slug
21
+ # - opaque service desk project key, matched verbatim
22
+ # against the claimed
23
+ # service_desk_settings.project_key_address_slug
24
+ # - nil - cannot be identified
25
+ Identification = Data.define(:handler, :attributes) do
26
+ def [](key)
27
+ attributes[key]
28
+ end
29
+
30
+ def project_id
31
+ attributes[:project_id]
32
+ end
33
+
34
+ def project_path
35
+ attributes[:project_path]
36
+ end
37
+
38
+ def project_slug
39
+ attributes[:project_slug]
40
+ end
41
+
42
+ def incoming_email_token
43
+ attributes[:incoming_email_token]
44
+ end
45
+
46
+ # Decodes the base36 namespace_id encoded in partitioned reply keys.
47
+ # Returns nil for legacy reply keys and partitioned keys that don't
48
+ # encode a namespace id.
49
+ def decoded_namespace_id
50
+ encoded = attributes[:namespace_id]
51
+ return unless encoded
52
+
53
+ encoded.to_i(ReplyKey::INTEGER_CONVERT_BASE)
54
+ end
55
+
56
+ # The identified Target for this email, or nil when it can't be identified.
57
+ def target
58
+ namespace_id = decoded_namespace_id
59
+
60
+ if namespace_id
61
+ Target.namespace_id(namespace_id)
62
+ elsif project_id
63
+ Target.project_id(project_id)
64
+ elsif project_path
65
+ Target.route(project_path)
66
+ elsif attributes[:project_key_address_slug]
67
+ Target.service_desk_project_key_address_slug(attributes[:project_key_address_slug])
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'matchers/create_note'
4
+ require_relative 'matchers/create_issue'
5
+ require_relative 'matchers/create_note_on_issuable'
6
+ require_relative 'matchers/unsubscribe'
7
+ require_relative 'matchers/create_merge_request'
8
+ require_relative 'matchers/service_desk'
9
+
10
+ module Gitlab
11
+ module EmailHandler
12
+ # Identifies which handler an incoming email key belongs to by trying each
13
+ # matcher in the same precedence order as
14
+ # Gitlab::Email::Handler.load_handlers.
15
+ module Identifier
16
+ # Order mirrors Gitlab::Email::Handler.load_handlers.
17
+ MATCHERS = [
18
+ Matchers::CreateNote,
19
+ Matchers::CreateIssue,
20
+ Matchers::CreateNoteOnIssuable,
21
+ Matchers::Unsubscribe,
22
+ Matchers::CreateMergeRequest,
23
+ Matchers::ServiceDesk
24
+ ].freeze
25
+
26
+ class << self
27
+ def matchers
28
+ @matchers ||= MATCHERS.map(&:new)
29
+ end
30
+
31
+ # @param mail_key [String]
32
+ # @return [Gitlab::EmailHandler::Identification, nil]
33
+ def call(mail_key)
34
+ return if mail_key.nil?
35
+
36
+ matchers.each do |matcher|
37
+ result = matcher.match(mail_key)
38
+ return result if result
39
+ end
40
+
41
+ nil
42
+ end
43
+
44
+ # Convenience: returns an Identification for a specific handler only.
45
+ # Used by the GitLab handlers which already know their own type.
46
+ def for_handler(handler_name, mail_key)
47
+ matcher = matchers.find { |m| m.handler_name == handler_name }
48
+ matcher&.match(mail_key)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module EmailHandler
5
+ # Extracts the mail key from an incoming email's headers.
6
+ #
7
+ # This is the single source of truth for *how* an incoming email is scanned
8
+ # for a mail key: the order in which recipient headers (and the references
9
+ # fallback) are tried, how a wildcard address is parsed into a key, and how a
10
+ # fallback message id is parsed. Both the GitLab application
11
+ # (Gitlab::Email::Receiver) and the standalone mail_room service consume this
12
+ # so they can never disagree on which key an email resolves to.
13
+ #
14
+ # The module is pure and config-free: callers pass the wildcard address and
15
+ # the GitLab host as arguments rather than reading them from any global
16
+ # configuration.
17
+ #
18
+ # Callers own *what* to do with each candidate (the application layers in
19
+ # database-backed Service Desk custom email lookups; the mail_room service
20
+ # maps each candidate to a routing Target), but never the ordering. They pass
21
+ # a block to `each_candidate`; the first non-nil value the block returns wins
22
+ # and iteration stops, mirroring the original short-circuiting behavior.
23
+ module MailKey
24
+ # Recipient sources, in the precedence the GitLab application has always
25
+ # used. `:references` is the message-id fallback and is tried after the
26
+ # primary `:to` header but before the remaining headers. `:received`
27
+ # extracts recipients from the Received headers.
28
+ RECIPIENT_HEADERS = %i[
29
+ delivered_to x_delivered_to envelope_to x_envelope_to received x_original_to x_forwarded_to cc
30
+ ].freeze
31
+
32
+ # Extracts the recipient address out of a `Received` header, e.g.
33
+ # Received: from x by y for <incoming+key@host>; <date>
34
+ RECEIVED_HEADER_REGEX = /for\s+<([^<]+)>/
35
+
36
+ WILDCARD_PLACEHOLDER = '%{key}'
37
+
38
+ # A single mail key candidate.
39
+ #
40
+ # - source: the header the candidate came from (e.g. :to, :references)
41
+ # - value: the raw recipient address, or message id for :references
42
+ # - key: the offline-parsed mail key for this candidate, or nil
43
+ Candidate = Data.define(:source, :value, :key)
44
+
45
+ module_function
46
+
47
+ # Yields each mail key candidate, in precedence order, to the given block.
48
+ # The first non-nil block result is returned and iteration stops. Returns
49
+ # nil when no candidate is found or the block matches nothing.
50
+ #
51
+ # @param mail [Mail::Message]
52
+ # @param wildcard_address [String, nil] the configured incoming/service
53
+ # desk address containing the `%{key}` placeholder
54
+ # @param gitlab_host [String, nil] the GitLab host, used to parse the
55
+ # references message-id fallback
56
+ # @yieldparam candidate [Candidate]
57
+ # @return the first non-nil block result, or nil
58
+ def each_candidate(mail, wildcard_address:, gitlab_host:)
59
+ return unless block_given?
60
+
61
+ # 1. The primary To header.
62
+ addresses(mail, :to).each do |address|
63
+ result = yield Candidate.new(source: :to, value: address, key: key_from_address(address, wildcard_address))
64
+ return result if result
65
+ end
66
+
67
+ # 2. The references message-id fallback.
68
+ reference_message_ids(mail).each do |mail_id|
69
+ result = yield Candidate.new(
70
+ source: :references,
71
+ value: mail_id,
72
+ key: key_from_fallback_message_id(mail_id, gitlab_host)
73
+ )
74
+ return result if result
75
+ end
76
+
77
+ # 3. The remaining recipient headers.
78
+ RECIPIENT_HEADERS.each do |source|
79
+ addresses(mail, source).each do |address|
80
+ result = yield Candidate.new(source: source, value: address,
81
+ key: key_from_address(address, wildcard_address))
82
+ return result if result
83
+ end
84
+ end
85
+
86
+ nil
87
+ end
88
+
89
+ # Parses a mail key out of a recipient address using the wildcard address.
90
+ #
91
+ # wildcard "incoming+%{key}@host", address "incoming+abc@host" => "abc"
92
+ #
93
+ # @return [String, nil]
94
+ def key_from_address(address, wildcard_address)
95
+ regex = address_regex(wildcard_address)
96
+ return unless regex
97
+
98
+ address.to_s.match(regex)&.[](1)
99
+ end
100
+
101
+ # Parses a mail key out of a fallback message id of the form
102
+ # `reply-<key>@<gitlab_host>`.
103
+ #
104
+ # @return [String, nil]
105
+ def key_from_fallback_message_id(mail_id, gitlab_host)
106
+ return unless gitlab_host
107
+
108
+ mail_id.to_s[/\Areply-(.+)@#{Regexp.escape(gitlab_host)}\z/, 1]
109
+ end
110
+
111
+ # Splits a raw References header value into individual message ids. Handles
112
+ # clients that join references with commas (e.g. Microsoft Exchange, iOS).
113
+ #
114
+ # @return [Array<String>]
115
+ def scan_fallback_references(references)
116
+ # Looks for each <...>
117
+ references.to_s.scan(/(?!<)[^<>]+(?=>)/)
118
+ end
119
+
120
+ # The addresses for a recipient source, normalized to strings.
121
+ #
122
+ # `:to` and `:cc` use the Mail::Message readers, which return arrays of
123
+ # individual address strings. Every other header is read as a raw field via
124
+ # `mail[header]`, matching how Gitlab::Email::Receiver has always read them.
125
+ def addresses(mail, source)
126
+ case source
127
+ when :to then Array(mail.to)
128
+ when :cc then Array(mail.cc)
129
+ when :received then received_recipients(mail)
130
+ else
131
+ Array(mail[source]).map { |item| item.is_a?(Mail::Field) ? item.value : item.to_s }
132
+ end
133
+ end
134
+
135
+ def received_recipients(mail)
136
+ Array(mail[:received]).filter_map { |header| header.value[RECEIVED_HEADER_REGEX, 1] }
137
+ end
138
+
139
+ def reference_message_ids(mail)
140
+ references = mail.references
141
+
142
+ case references
143
+ when Array then references
144
+ when String then scan_fallback_references(references)
145
+ else []
146
+ end
147
+ end
148
+
149
+ def address_regex(wildcard_address)
150
+ return unless wildcard_address
151
+
152
+ escaped = Regexp.escape(wildcard_address).sub(Regexp.escape(WILDCARD_PLACEHOLDER), '(.+)')
153
+ /\A<?#{escaped}>?\z/
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../reply_key'
4
+ require_relative '../identification'
5
+
6
+ module Gitlab
7
+ module EmailHandler
8
+ module Matchers
9
+ # Base class for handler matchers. Each matcher mirrors exactly one email
10
+ # handler's mail key parsing and the database-free portion of its
11
+ # `can_handle?` logic. Matchers never touch the database or the network.
12
+ class Base
13
+ # @param mail_key [String]
14
+ # @return [Gitlab::EmailHandler::Identification, nil]
15
+ def match(mail_key)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # The handler name this matcher corresponds to (e.g. :create_issue).
20
+ # @return [Symbol]
21
+ def handler_name
22
+ raise NotImplementedError
23
+ end
24
+
25
+ private
26
+
27
+ def identification(**attributes)
28
+ Identification.new(handler: handler_name, attributes: attributes)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Gitlab
6
+ module EmailHandler
7
+ module Matchers
8
+ # Mirrors Gitlab::Email::Handler::CreateIssueHandler.
9
+ # incoming+gitlab-org-gitlab-ce-20-Author_Token-issue@incoming.gitlab.com
10
+ # incoming+gitlab-org/gitlab-ce+Author_Token@incoming.gitlab.com (legacy)
11
+ class CreateIssue < Base
12
+ HANDLER_REGEX = /\A#{ReplyKey::HANDLER_ACTION_BASE_REGEX}-(?<incoming_email_token>.+)-issue\z/
13
+ HANDLER_REGEX_LEGACY = /\A(?<project_path>[^+]*)\+(?<incoming_email_token>.*)\z/
14
+
15
+ def match(mail_key)
16
+ key = mail_key.to_s
17
+
18
+ matched = HANDLER_REGEX.match(key) unless mail_key&.include?('/')
19
+ if matched
20
+ return identification(
21
+ project_slug: matched[:project_slug],
22
+ project_id: matched[:project_id]&.to_i,
23
+ incoming_email_token: matched[:incoming_email_token]
24
+ )
25
+ end
26
+
27
+ matched = HANDLER_REGEX_LEGACY.match(key)
28
+ return unless matched
29
+
30
+ project_path = matched[:project_path]
31
+ incoming_email_token = matched[:incoming_email_token]
32
+ return unless can_handle_legacy_format?(project_path, incoming_email_token, key)
33
+
34
+ identification(
35
+ project_path: project_path,
36
+ incoming_email_token: incoming_email_token
37
+ )
38
+ end
39
+
40
+ def handler_name
41
+ :create_issue
42
+ end
43
+
44
+ private
45
+
46
+ def can_handle_legacy_format?(project_path, incoming_email_token, mail_key)
47
+ project_path &&
48
+ incoming_email_token &&
49
+ !incoming_email_token.include?('+') &&
50
+ !mail_key.include?(ReplyKey::UNSUBSCRIBE_SUFFIX_LEGACY)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Gitlab
6
+ module EmailHandler
7
+ module Matchers
8
+ # Mirrors Gitlab::Email::Handler::CreateMergeRequestHandler.
9
+ # incoming+gitlab-org-gitlab-ce-20-Author_Token-merge-request@incoming.gitlab.com
10
+ # incoming+gitlab-org/gitlab-ce+merge-request+Author_Token@incoming.gitlab.com (legacy)
11
+ class CreateMergeRequest < Base
12
+ HANDLER_REGEX = /\A#{ReplyKey::HANDLER_ACTION_BASE_REGEX}-(?<incoming_email_token>.+)-merge-request\z/
13
+ HANDLER_REGEX_LEGACY = /\A(?<project_path>[^+]*)\+merge-request\+(?<incoming_email_token>.*)/
14
+
15
+ def match(mail_key)
16
+ key = mail_key.to_s
17
+
18
+ matched = HANDLER_REGEX.match(key) unless mail_key&.include?('/')
19
+ if matched
20
+ return identification(
21
+ project_slug: matched[:project_slug],
22
+ project_id: matched[:project_id]&.to_i,
23
+ incoming_email_token: matched[:incoming_email_token]
24
+ )
25
+ end
26
+
27
+ matched = HANDLER_REGEX_LEGACY.match(key)
28
+ return unless matched
29
+
30
+ identification(
31
+ project_path: matched[:project_path],
32
+ incoming_email_token: matched[:incoming_email_token]
33
+ )
34
+ end
35
+
36
+ def handler_name
37
+ :create_merge_request
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Gitlab
6
+ module EmailHandler
7
+ module Matchers
8
+ # Mirrors Gitlab::Email::Handler::CreateNoteHandler.
9
+ # incoming+<reply_key>@incoming.gitlab.com
10
+ class CreateNote < Base
11
+ HANDLER_REGEX = /\A#{ReplyKey::FULL_REPLY_KEY_REGEX}\z/
12
+
13
+ def match(mail_key)
14
+ matched = HANDLER_REGEX.match(mail_key.to_s)
15
+ return unless matched
16
+
17
+ identification(
18
+ reply_key: matched[:reply_key],
19
+ namespace_id: matched[:namespace_id],
20
+ legacy_key: matched[:legacy_key]
21
+ )
22
+ end
23
+
24
+ def handler_name
25
+ :create_note
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Gitlab
6
+ module EmailHandler
7
+ module Matchers
8
+ # Mirrors Gitlab::Email::Handler::CreateNoteOnIssuableHandler.
9
+ # incoming+gitlab-org-gitlab-ce-20-Author_Token-issue-34@incoming.gitlab.com
10
+ class CreateNoteOnIssuable < Base
11
+ HANDLER_REGEX =
12
+ /\A#{ReplyKey::HANDLER_ACTION_BASE_REGEX}-(?<incoming_email_token>.+)-issue-(?<issuable_iid>\d+)\z/
13
+
14
+ def match(mail_key)
15
+ matched = HANDLER_REGEX.match(mail_key.to_s)
16
+ return unless matched
17
+
18
+ identification(
19
+ project_slug: matched[:project_slug],
20
+ project_id: matched[:project_id]&.to_i,
21
+ incoming_email_token: matched[:incoming_email_token],
22
+ issuable_iid: matched[:issuable_iid]&.to_i
23
+ )
24
+ end
25
+
26
+ def handler_name
27
+ :create_note_on_issuable
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Gitlab
6
+ module EmailHandler
7
+ module Matchers
8
+ # Mirrors Gitlab::Email::Handler::ServiceDeskHandler (parsing portion).
9
+ # incoming+gitlab-org-gitlab-ce-20-issue-@incoming.gitlab.com
10
+ # incoming+gitlab-org/gitlab-ce@incoming.gitlab.com (legacy)
11
+ # support+gitlab-org-gitlab-ce-abc123_def@service-desk.gitlab.com (project key)
12
+ #
13
+ # The opaque project key form (slug-key) is stored whole in
14
+ # `service_desk_settings.project_key_address_slug` and claimed as unique
15
+ # in the Topology Service, so it is identifiable without a database
16
+ # lookup. Resolving which project it belongs to remains an app-side
17
+ # concern (Project.with_service_desk_key). The custom email form is
18
+ # resolvable via a Topology Service lookup.
19
+ class ServiceDesk < Base
20
+ HANDLER_REGEX = /\A#{ReplyKey::HANDLER_ACTION_BASE_REGEX}-issue-\z/
21
+ HANDLER_REGEX_LEGACY = /\A(?<project_path>[^+]*)\z/
22
+ # Parses an opaque service desk key of the form `<slug>-<key>`. The key
23
+ # is resolved against the database (Project.with_service_desk_key) so
24
+ # this stays an app-side concern, but the pattern itself lives here to
25
+ # keep all email key parsing in one place.
26
+ PROJECT_KEY_PATTERN = /\A(?<slug>.+)-(?<key>[a-z0-9_]+)\z/
27
+
28
+ def match(mail_key)
29
+ key = mail_key.to_s
30
+
31
+ matched = HANDLER_REGEX.match(key) unless mail_key&.include?('/')
32
+ if matched
33
+ return identification(
34
+ project_slug: matched[:project_slug],
35
+ project_id: matched[:project_id]&.to_i
36
+ )
37
+ end
38
+
39
+ matched = HANDLER_REGEX_LEGACY.match(key)
40
+ if matched && can_handle_legacy_format?(matched[:project_path], key)
41
+ return identification(project_path: matched[:project_path])
42
+ end
43
+
44
+ match_project_key(key)
45
+ end
46
+
47
+ def handler_name
48
+ :service_desk
49
+ end
50
+
51
+ private
52
+
53
+ def can_handle_legacy_format?(project_path, mail_key)
54
+ project_path&.include?('/') && !mail_key.include?('+')
55
+ end
56
+
57
+ # An opaque `<slug>-<key>` project key. The whole concatenated string is
58
+ # what `service_desk_settings.project_key_address_slug` stores and the
59
+ # Topology Service claims, so no splitting is needed: both parts are
60
+ # already lowercase by validation, and the composite value is matched
61
+ # verbatim.
62
+ def match_project_key(key)
63
+ return unless PROJECT_KEY_PATTERN.match?(key)
64
+
65
+ identification(project_key_address_slug: key)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Gitlab
6
+ module EmailHandler
7
+ module Matchers
8
+ # Mirrors Gitlab::Email::Handler::UnsubscribeHandler.
9
+ # incoming+<reply_key>-unsubscribe@incoming.gitlab.com
10
+ # incoming+<reply_key>+unsubscribe@incoming.gitlab.com (legacy)
11
+ class Unsubscribe < Base
12
+ HANDLER_REGEX_FOR = ->(suffix) {
13
+ /\A(?<reply_token>#{ReplyKey::FULL_REPLY_KEY_REGEX})#{Regexp.escape(suffix)}\z/
14
+ }.freeze
15
+ HANDLER_REGEX = HANDLER_REGEX_FOR.call(ReplyKey::UNSUBSCRIBE_SUFFIX).freeze
16
+ HANDLER_REGEX_LEGACY = HANDLER_REGEX_FOR.call(ReplyKey::UNSUBSCRIBE_SUFFIX_LEGACY).freeze
17
+
18
+ def match(mail_key)
19
+ key = mail_key.to_s
20
+ matched = HANDLER_REGEX.match(key) || HANDLER_REGEX_LEGACY.match(key)
21
+ return unless matched
22
+
23
+ identification(
24
+ reply_token: matched[:reply_token],
25
+ reply_key: matched[:reply_key],
26
+ namespace_id: matched[:namespace_id],
27
+ legacy_key: matched[:legacy_key]
28
+ )
29
+ end
30
+
31
+ def handler_name
32
+ :unsubscribe
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module EmailHandler
5
+ # Single source of truth for the regular expressions and constants used to
6
+ # parse incoming email keys. These were previously defined inline in
7
+ # `SentNotification` and the email handlers. Keeping them here lets both the
8
+ # GitLab application and the standalone mail_room identification service
9
+ # share the exact same parsing rules.
10
+ module ReplyKey
11
+ REPLY_KEY_BYTE_SIZE = 16
12
+ INTEGER_CONVERT_BASE = 36
13
+ BASE36_REGEX = /[0-9a-z]/
14
+ NAMESPACE_REGEX = /(?:-(?<namespace_id>#{BASE36_REGEX}{1,13}))?/
15
+ # Email reply key is in the form: <base36-partition-id>-<base36-reply-key>-<base36-namespace-id>
16
+ PARTITIONED_REPLY_KEY_REGEX =
17
+ /(?<partition>#{BASE36_REGEX}{1,4})-(?<reply_key>#{BASE36_REGEX}{25})#{NAMESPACE_REGEX}/
18
+ LEGACY_REPLY_KEY_REGEX = /(?<legacy_key>[a-f\d]{32})/
19
+ FULL_REPLY_KEY_REGEX = /(?:(#{LEGACY_REPLY_KEY_REGEX})|(#{PARTITIONED_REPLY_KEY_REGEX}))/
20
+
21
+ # Base regex shared by handlers that encode `<project_slug>-<project_id>` in
22
+ # the mail key.
23
+ HANDLER_ACTION_BASE_REGEX = /(?<project_slug>.+)-(?<project_id>\d+)/
24
+
25
+ UNSUBSCRIBE_SUFFIX = '-unsubscribe'
26
+ UNSUBSCRIBE_SUFFIX_LEGACY = '+unsubscribe'
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module EmailHandler
5
+ # The identified target of an incoming email: the kind of resource that owns
6
+ # it and the value that locates it. This is a plain value object with no
7
+ # knowledge of how the target is later resolved or routed.
8
+ #
9
+ # `kind` is one of:
10
+ # :project_id, :namespace_id, :route, :service_desk_custom_email,
11
+ # :service_desk_project_key_address_slug
12
+ Target = Data.define(:kind, :value) do
13
+ def self.project_id(value)
14
+ new(kind: :project_id, value: value)
15
+ end
16
+
17
+ def self.namespace_id(value)
18
+ new(kind: :namespace_id, value: value)
19
+ end
20
+
21
+ def self.route(path)
22
+ new(kind: :route, value: path.to_s.split('/').first)
23
+ end
24
+
25
+ def self.service_desk_custom_email(value)
26
+ new(kind: :service_desk_custom_email, value: value)
27
+ end
28
+
29
+ def self.service_desk_project_key_address_slug(value)
30
+ new(kind: :service_desk_project_key_address_slug, value: value)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module EmailHandler
5
+ module Version
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'email_handler/version'
4
+
5
+ # Pure, dependency-free incoming email identification: regex parsing of mail
6
+ # keys into a Target. No Rails, gRPC or HTTP dependencies. Resolving a Target to
7
+ # a cell and forwarding the email are the responsibility of the consumer (the
8
+ # mail_room service), not this gem.
9
+ require_relative 'email_handler/reply_key'
10
+ require_relative 'email_handler/target'
11
+ require_relative 'email_handler/identification'
12
+ require_relative 'email_handler/identifier'
13
+ require_relative 'email_handler/custom_email'
14
+ require_relative 'email_handler/mail_key'
metadata CHANGED
@@ -1,14 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-email_handler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
- - group::service desk
7
+ - group::project management
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: gitlab-styles
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '14.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '14.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mail
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.8'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
12
55
  description: Parses incoming email keys to identify the target handler and the resource
13
56
  (project, namespace, or route) that owns the email.
14
57
  email:
@@ -17,12 +60,29 @@ executables: []
17
60
  extensions: []
18
61
  extra_rdoc_files: []
19
62
  files:
20
- - lib/gitlab-email_handler.rb
21
- homepage: https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/gitlab-email_handler
63
+ - lib/gitlab/email_handler.rb
64
+ - lib/gitlab/email_handler/custom_email.rb
65
+ - lib/gitlab/email_handler/identification.rb
66
+ - lib/gitlab/email_handler/identifier.rb
67
+ - lib/gitlab/email_handler/mail_key.rb
68
+ - lib/gitlab/email_handler/matchers/base.rb
69
+ - lib/gitlab/email_handler/matchers/create_issue.rb
70
+ - lib/gitlab/email_handler/matchers/create_merge_request.rb
71
+ - lib/gitlab/email_handler/matchers/create_note.rb
72
+ - lib/gitlab/email_handler/matchers/create_note_on_issuable.rb
73
+ - lib/gitlab/email_handler/matchers/service_desk.rb
74
+ - lib/gitlab/email_handler/matchers/unsubscribe.rb
75
+ - lib/gitlab/email_handler/reply_key.rb
76
+ - lib/gitlab/email_handler/target.rb
77
+ - lib/gitlab/email_handler/version.rb
78
+ homepage: https://gitlab.com/gitlab-org/ruby/gems/gitlab-email_handler
22
79
  licenses:
23
80
  - MIT
24
81
  metadata:
25
82
  rubygems_mfa_required: 'true'
83
+ source_code_uri: https://gitlab.com/gitlab-org/ruby/gems/gitlab-email_handler
84
+ changelog_uri: https://gitlab.com/gitlab-org/ruby/gems/gitlab-email_handler/-/releases
85
+ post_install_message:
26
86
  rdoc_options: []
27
87
  require_paths:
28
88
  - lib
@@ -37,7 +97,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
37
97
  - !ruby/object:Gem::Version
38
98
  version: '0'
39
99
  requirements: []
40
- rubygems_version: 3.6.7
100
+ rubygems_version: 3.5.22
101
+ signing_key:
41
102
  specification_version: 4
42
103
  summary: Incoming email identification for GitLab
43
104
  test_files: []
@@ -1,3 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- raise "Reserved for GitLab"