Skip to content

Commit 7e6da2b

Browse files
committed
Add email polling mechanism
1 parent f9b69ff commit 7e6da2b

11 files changed

Lines changed: 1041 additions & 4 deletions
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.pir.impl.common
18+
19+
import com.duckduckgo.common.utils.DispatcherProvider
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.duckduckgo.pir.impl.common.EmailDataResolver.EmailDataResolverResult
22+
import com.duckduckgo.pir.impl.common.EmailDataResolver.EmailDataResolverResult.Failure
23+
import com.duckduckgo.pir.impl.common.EmailDataResolver.EmailDataResolverResult.Pending
24+
import com.duckduckgo.pir.impl.common.EmailDataResolver.EmailDataResolverResult.Success
25+
import com.duckduckgo.pir.impl.service.DbpService
26+
import com.duckduckgo.pir.impl.service.DbpService.PirEmailConfirmationDataRequest
27+
import com.duckduckgo.pir.impl.service.DbpService.PirEmailConfirmationDataRequest.RequestEmailData
28+
import com.duckduckgo.pir.impl.service.ResponseError
29+
import com.duckduckgo.pir.impl.service.parseError
30+
import com.squareup.anvil.annotations.ContributesBinding
31+
import com.squareup.moshi.Moshi
32+
import kotlinx.coroutines.withContext
33+
import logcat.logcat
34+
import retrofit2.HttpException
35+
import javax.inject.Inject
36+
37+
interface EmailDataResolver {
38+
/**
39+
* Polls the backend for email-extracted data (e.g. verification codes) for the given email/attempt pair.
40+
*
41+
* @param emailAddress - the generated email address whose inbox is being polled
42+
* @param attemptId - identifies the scan or opt-out attempt
43+
*/
44+
suspend fun poll(
45+
emailAddress: String,
46+
attemptId: String,
47+
): EmailDataResolverResult
48+
49+
sealed class EmailDataResolverResult {
50+
/** Backend returned status "ready" with the extracted data map. */
51+
data class Success(
52+
val extractedData: Map<String, String>,
53+
) : EmailDataResolverResult()
54+
55+
/** Backend has not yet received the email — caller should poll again. */
56+
data object Pending : EmailDataResolverResult()
57+
58+
data class Failure(
59+
val code: Int,
60+
val message: String,
61+
) : EmailDataResolverResult()
62+
}
63+
}
64+
65+
@ContributesBinding(AppScope::class)
66+
class RealEmailDataResolver @Inject constructor(
67+
private val dbpService: DbpService,
68+
private val dispatcherProvider: DispatcherProvider,
69+
moshi: Moshi,
70+
) : EmailDataResolver {
71+
private val adapter = moshi.adapter(ResponseError::class.java)
72+
73+
override suspend fun poll(
74+
emailAddress: String,
75+
attemptId: String,
76+
): EmailDataResolverResult = withContext(dispatcherProvider.io()) {
77+
runCatching {
78+
dbpService.getEmailConfirmationLinkStatus(
79+
PirEmailConfirmationDataRequest(
80+
items = listOf(
81+
RequestEmailData(
82+
email = emailAddress,
83+
attemptId = attemptId,
84+
),
85+
),
86+
),
87+
).run {
88+
logcat { "PIR-EMAIL-DATA: RESULT -> $this" }
89+
val item = items.firstOrNull()
90+
?: return@run Failure(
91+
code = 0,
92+
message = PREFIX_EMAIL_DATA_ERROR + "Empty response items",
93+
)
94+
95+
when (item.status.lowercase()) {
96+
STATUS_READY -> Success(
97+
extractedData = item.data.associate { it.name to it.value },
98+
)
99+
100+
STATUS_PENDING -> Pending
101+
102+
else -> Failure(
103+
code = 0,
104+
message = "$PREFIX_EMAIL_DATA_ERROR${item.errorCode.orEmpty()} ${item.error ?: item.status}",
105+
)
106+
}
107+
}
108+
}.getOrElse { error ->
109+
logcat { "PIR-EMAIL-DATA: Failure -> $error" }
110+
if (error is HttpException) {
111+
val errorMessage = adapter.parseError(error)?.message.orEmpty()
112+
Failure(
113+
code = error.code(),
114+
message = "$PREFIX_EMAIL_DATA_ERROR${error.code()} $errorMessage",
115+
)
116+
} else {
117+
Failure(
118+
code = 0,
119+
message = PREFIX_EMAIL_DATA_ERROR + (error.message ?: "Unknown error"),
120+
)
121+
}
122+
}
123+
}
124+
125+
companion object {
126+
private const val PREFIX_EMAIL_DATA_ERROR = "Email data poll error: "
127+
private const val STATUS_READY = "ready"
128+
private const val STATUS_PENDING = "pending"
129+
}
130+
}

pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/PirActionsRunner.kt

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,20 @@ import com.duckduckgo.pir.impl.common.PirJob.RunType
3535
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine
3636
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event
3737
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.CaptchaInfoReceived
38+
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.EmailDataReceived
3839
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.EmailReceived
3940
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ErrorReceived
4041
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ExecuteBrokerStepAction
4142
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.JsActionSuccess
4243
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.LoadUrlComplete
4344
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.LoadUrlFailed
4445
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.RetryAwaitCaptchaSolution
46+
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.RetryAwaitEmailData
4547
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.RetryGetCaptchaSolution
4648
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.Started
4749
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect
4850
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.AwaitCaptchaSolution
51+
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.AwaitEmailData
4952
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.CompleteExecution
5053
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.EvaluateJs
5154
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.GetCaptchaSolution
@@ -117,6 +120,7 @@ class RealPirActionsRunner @AssistedInject constructor(
117120
private val pirDetachedWebViewProvider: PirDetachedWebViewProvider,
118121
private val brokerActionProcessor: BrokerActionProcessor,
119122
private val nativeBrokerActionHandler: NativeBrokerActionHandler,
123+
private val emailDataResolver: EmailDataResolver,
120124
private val engineFactory: PirActionsRunnerStateEngineFactory,
121125
@AppCoroutineScope private val coroutineScope: CoroutineScope,
122126
@Assisted private val runType: RunType,
@@ -271,7 +275,7 @@ class RealPirActionsRunner @AssistedInject constructor(
271275
}
272276

273277
is AwaitCaptchaSolution -> handleAwaitCaptchaSolution(effect)
274-
is SideEffect.AwaitEmailData -> handleAwaitEmailData(effect)
278+
is AwaitEmailData -> handleAwaitEmailData(effect)
275279
}
276280
}
277281

@@ -359,9 +363,77 @@ class RealPirActionsRunner @AssistedInject constructor(
359363
}
360364
}
361365

362-
private suspend fun handleAwaitEmailData(effect: SideEffect.AwaitEmailData) {
363-
// TODO: Implement in step 5 — poll for email data via EmailDataResolver
364-
}
366+
private suspend fun handleAwaitEmailData(effect: AwaitEmailData) =
367+
withContext(dispatcherProvider.io()) {
368+
if (effect.emailAddress.isEmpty() || effect.attemptId.isEmpty()) {
369+
onError(
370+
ClientError(
371+
actionID = effect.actionId,
372+
message = "Invalid state: missing email address or attempt id for email data poll",
373+
),
374+
)
375+
return@withContext
376+
}
377+
378+
val maxAttempts = if (effect.pollingIntervalSeconds > 0) {
379+
effect.maxTimeoutSeconds / effect.pollingIntervalSeconds
380+
} else {
381+
0
382+
}
383+
384+
when (val result = emailDataResolver.poll(effect.emailAddress, effect.attemptId)) {
385+
is EmailDataResolver.EmailDataResolverResult.Success -> {
386+
if (effect.extractFields.all { result.extractedData.containsKey(it) }) {
387+
engine?.dispatch(
388+
EmailDataReceived(
389+
emailExtractedData = result.extractedData,
390+
),
391+
)
392+
} else {
393+
onError(
394+
ClientError(
395+
actionID = effect.actionId,
396+
message = "Email data ready but missing required fields: ${effect.extractFields - result.extractedData.keys}",
397+
),
398+
)
399+
}
400+
}
401+
402+
is EmailDataResolver.EmailDataResolverResult.Pending -> {
403+
if (effect.attempt >= maxAttempts) {
404+
onError(
405+
ClientError(
406+
actionID = effect.actionId,
407+
message = "Email data poll timeout after ${effect.maxTimeoutSeconds}s",
408+
),
409+
)
410+
} else {
411+
delay(effect.pollingIntervalSeconds * 1000L)
412+
engine?.dispatch(
413+
RetryAwaitEmailData(
414+
actionId = effect.actionId,
415+
brokerName = effect.brokerName,
416+
emailAddress = effect.emailAddress,
417+
attemptId = effect.attemptId,
418+
extractFields = effect.extractFields,
419+
pollingIntervalSeconds = effect.pollingIntervalSeconds,
420+
maxTimeoutSeconds = effect.maxTimeoutSeconds,
421+
attempt = effect.attempt,
422+
),
423+
)
424+
}
425+
}
426+
427+
is EmailDataResolver.EmailDataResolverResult.Failure -> {
428+
onError(
429+
ClientError(
430+
actionID = effect.actionId,
431+
message = result.message,
432+
),
433+
)
434+
}
435+
}
436+
}
365437

366438
private suspend fun handleGetCaptchaSolution(effect: GetCaptchaSolution) =
367439
withContext(dispatcherProvider.io()) {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.pir.impl.common.actions
18+
19+
import com.duckduckgo.di.scopes.AppScope
20+
import com.duckduckgo.pir.impl.common.actions.EventHandler.Next
21+
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event
22+
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.EmailDataReceived
23+
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ExecuteBrokerStepAction
24+
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.State
25+
import com.duckduckgo.pir.impl.scripts.models.PirScriptRequestData.UserProfile
26+
import com.squareup.anvil.annotations.ContributesMultibinding
27+
import javax.inject.Inject
28+
import kotlin.reflect.KClass
29+
30+
@ContributesMultibinding(
31+
scope = AppScope::class,
32+
boundType = EventHandler::class,
33+
)
34+
class EmailDataReceivedEventHandler @Inject constructor() : EventHandler {
35+
override val event: KClass<out Event> = EmailDataReceived::class
36+
37+
override suspend fun invoke(
38+
state: State,
39+
event: Event,
40+
): Next {
41+
val actualEvent = event as EmailDataReceived
42+
return Next(
43+
nextState = state.copy(
44+
currentActionIndex = state.currentActionIndex + 1,
45+
emailExtractedData = actualEvent.emailExtractedData,
46+
),
47+
nextEvent = ExecuteBrokerStepAction(
48+
actionRequestData = UserProfile(
49+
userProfile = state.profileQuery,
50+
),
51+
),
52+
)
53+
}
54+
}

pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/common/actions/ExecuteBrokerStepActionEventHandler.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.
3434
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.Event.ExecuteBrokerStepAction
3535
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.PirStageStatus
3636
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.AwaitCaptchaSolution
37+
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.AwaitEmailData
3738
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.GetEmailForProfile
3839
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.LoadUrl
3940
import com.duckduckgo.pir.impl.common.actions.PirActionsRunnerStateEngine.SideEffect.PushJsAction
@@ -48,6 +49,7 @@ import com.duckduckgo.pir.impl.scripts.models.BrokerAction.Expectation
4849
import com.duckduckgo.pir.impl.scripts.models.BrokerAction.FillForm
4950
import com.duckduckgo.pir.impl.scripts.models.BrokerAction.GenerateEmail
5051
import com.duckduckgo.pir.impl.scripts.models.BrokerAction.GetCaptchaInfo
52+
import com.duckduckgo.pir.impl.scripts.models.BrokerAction.GetEmailData
5153
import com.duckduckgo.pir.impl.scripts.models.BrokerAction.SolveCaptcha
5254
import com.duckduckgo.pir.impl.scripts.models.DataSource.EXTRACTED_PROFILE
5355
import com.duckduckgo.pir.impl.scripts.models.PirError
@@ -125,6 +127,23 @@ class ExecuteBrokerStepActionEventHandler @Inject constructor(
125127
brokerName = currentBrokerStep.broker.name,
126128
),
127129
)
130+
} else if (actionToExecute is GetEmailData) {
131+
Next(
132+
nextState = state.copy(
133+
stageStatus = PirStageStatus(
134+
currentStage = PirStage.EMAIL_DATA_POLL,
135+
stageStartMs = currentTimeProvider.currentTimeMillis(),
136+
),
137+
),
138+
sideEffect = AwaitEmailData(
139+
actionId = actionToExecute.id,
140+
brokerName = currentBrokerStep.broker.name,
141+
emailAddress = state.generatedEmailData?.emailAddress.orEmpty(),
142+
attemptId = state.attemptId,
143+
extractFields = actionToExecute.extract,
144+
pollingIntervalSeconds = actionToExecute.pollingTime.toIntOrNull() ?: DEFAULT_EMAIL_DATA_POLL_INTERVAL_SECONDS,
145+
),
146+
)
128147
} else {
129148
var pushDelay = 0L
130149
// Adding a delay here similar to macOS - to ensure the site completes loading before executing anything.
@@ -305,4 +324,8 @@ class ExecuteBrokerStepActionEventHandler @Inject constructor(
305324
requestData
306325
}
307326
}
327+
328+
companion object {
329+
private const val DEFAULT_EMAIL_DATA_POLL_INTERVAL_SECONDS = 5
330+
}
308331
}

0 commit comments

Comments
 (0)