1mod account;
2mod configuration_sets;
3mod contact_lists;
4mod identities;
5mod misc;
6mod sending;
7mod suppression;
8pub(crate) mod templates;
9
10use async_trait::async_trait;
11use http::{Method, StatusCode};
12use serde_json::{json, Value};
13use std::sync::Arc;
14use tokio::sync::Mutex as AsyncMutex;
15
16use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
17use fakecloud_persistence::SnapshotStore;
18
19use crate::fanout::SesDeliveryContext;
20use crate::state::{
21 EventDestination, SesSnapshot, SharedSesState, Topic, TopicPreference,
22 SES_SNAPSHOT_SCHEMA_VERSION,
23};
24
25pub struct SesV2Service {
26 state: SharedSesState,
27 delivery_ctx: Option<SesDeliveryContext>,
28 snapshot_store: Option<Arc<dyn SnapshotStore>>,
29 snapshot_lock: Arc<AsyncMutex<()>>,
30}
31
32impl SesV2Service {
33 pub fn new(state: SharedSesState) -> Self {
34 Self {
35 state,
36 delivery_ctx: None,
37 snapshot_store: None,
38 snapshot_lock: Arc::new(AsyncMutex::new(())),
39 }
40 }
41
42 pub fn with_delivery(mut self, ctx: SesDeliveryContext) -> Self {
44 self.delivery_ctx = Some(ctx);
45 self
46 }
47
48 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
49 self.snapshot_store = Some(store);
50 self
51 }
52
53 async fn save_snapshot(&self) {
57 save_ses_snapshot(
58 &self.state,
59 self.snapshot_store.clone(),
60 &self.snapshot_lock,
61 )
62 .await;
63 }
64
65 pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
70 let store = self.snapshot_store.clone()?;
71 let state = self.state.clone();
72 let lock = self.snapshot_lock.clone();
73 Some(Arc::new(move || {
74 let state = state.clone();
75 let store = store.clone();
76 let lock = lock.clone();
77 Box::pin(async move {
78 save_ses_snapshot(&state, Some(store), &lock).await;
79 })
80 }))
81 }
82
83 fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Option<String>, Option<String>)> {
166 let raw = req
173 .raw_path
174 .split_once('?')
175 .map(|(p, _)| p)
176 .unwrap_or(&req.raw_path);
177 let trimmed = raw.trim_start_matches('/');
178 if trimmed.is_empty() {
179 return None;
180 }
181 let raw_segs: Vec<&str> = trimmed.split('/').collect();
182 if raw_segs.iter().any(|s| s.is_empty()) {
186 return None;
187 }
188 let has_placeholder = raw_segs.iter().any(|seg| {
192 let decoded = percent_encoding::percent_decode_str(seg)
193 .decode_utf8_lossy()
194 .into_owned();
195 decoded.starts_with('{') && decoded.ends_with('}')
196 });
197 if has_placeholder {
198 return None;
199 }
200
201 let segs = &req.path_segments;
202
203 if segs.len() < 3 || segs[0] != "v2" || segs[1] != "email" {
204 return None;
205 }
206
207 let method = &req.method;
208 let resource = segs.get(3).map(|s| decode_segment(s));
209 let collection = segs[2].as_str();
210
211 match collection {
212 "account" => resolve_account_action(method, segs),
213 "identities" => resolve_identities_action(method, segs, resource),
214 "configuration-sets" => resolve_configuration_sets_action(method, segs, resource),
215 "templates" => resolve_templates_action(method, segs, resource),
216 "contact-lists" => resolve_contact_lists_action(method, segs, resource),
217 "suppression" => resolve_suppression_action(method, segs),
218 "tags" if segs.len() == 3 => match *method {
219 Method::POST => Some(("TagResource", None, None)),
220 Method::DELETE => Some(("UntagResource", None, None)),
221 Method::GET => Some(("ListTagsForResource", None, None)),
222 _ => None,
223 },
224 "outbound-emails" if segs.len() == 3 && *method == Method::POST => {
225 Some(("SendEmail", None, None))
226 }
227 "outbound-bulk-emails" if segs.len() == 3 && *method == Method::POST => {
228 Some(("SendBulkEmail", None, None))
229 }
230 "outbound-custom-verification-emails" if segs.len() == 3 && *method == Method::POST => {
231 Some(("SendCustomVerificationEmail", None, None))
232 }
233 "custom-verification-email-templates" => {
234 resolve_custom_verification_template_action(method, segs, resource)
235 }
236 "dedicated-ip-pools" => resolve_dedicated_ip_pools_action(method, segs, resource),
237 "dedicated-ips" => resolve_dedicated_ips_action(method, segs, resource),
238 "multi-region-endpoints" => {
239 resolve_multi_region_endpoints_action(method, segs, resource)
240 }
241 "import-jobs" => resolve_import_jobs_action(method, segs, resource),
242 "export-jobs" => resolve_export_jobs_action(method, segs, resource),
243 "list-export-jobs" if segs.len() == 3 && *method == Method::POST => {
244 Some(("ListExportJobs", None, None))
245 }
246 "tenants" => resolve_tenants_action(method, segs),
247 "tenant" if segs.len() == 4 && segs[3] == "suppression" && *method == Method::POST => {
248 Some(("PutTenantSuppressionAttributes", None, None))
249 }
250 "resources" => resolve_resources_action(method, segs),
251 "reputation" => resolve_reputation_action(method, segs),
252 "metrics" if segs.len() == 4 && segs[3] == "batch" && *method == Method::POST => {
253 Some(("BatchGetMetricData", None, None))
254 }
255 "deliverability-dashboard" => resolve_deliverability_dashboard_action(method, segs),
256 "email-address-insights" if segs.len() == 3 && *method == Method::POST => {
257 Some(("GetEmailAddressInsights", None, None))
258 }
259 "insights" if segs.len() == 4 && *method == Method::GET => {
260 Some(("GetMessageInsights", resource, None))
261 }
262 "vdm" if segs.len() == 4 && segs[3] == "recommendations" && *method == Method::POST => {
263 Some(("ListRecommendations", None, None))
264 }
265 _ => None,
266 }
267 }
268
269 fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
270 serde_json::from_slice(&req.body).map_err(|_| {
271 AwsServiceError::aws_error(
272 StatusCode::BAD_REQUEST,
273 "BadRequestException",
274 "Invalid JSON in request body",
275 )
276 })
277 }
278
279 fn require_nonempty(field: &str, value: &str) -> Result<(), AwsServiceError> {
281 if value.is_empty() {
282 Err(AwsServiceError::aws_error(
283 StatusCode::BAD_REQUEST,
284 "BadRequestException",
285 format!("{field} is required"),
286 ))
287 } else {
288 Ok(())
289 }
290 }
291
292 fn json_error(status: StatusCode, code: &str, message: &str) -> AwsResponse {
293 let body = json!({
294 "__type": code,
295 "message": message,
296 });
297 AwsResponse::json(status, body.to_string())
298 }
299}
300
301pub async fn save_ses_snapshot(
307 state: &SharedSesState,
308 store: Option<Arc<dyn SnapshotStore>>,
309 lock: &AsyncMutex<()>,
310) {
311 let Some(store) = store else {
312 return;
313 };
314 let _guard = lock.lock().await;
315 let snapshot = SesSnapshot {
316 schema_version: SES_SNAPSHOT_SCHEMA_VERSION,
317 accounts: Some(state.read().clone()),
318 state: None,
319 };
320 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
321 let bytes = serde_json::to_vec(&snapshot)
322 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
323 store.save(&bytes)
324 })
325 .await;
326 match join {
327 Ok(Ok(())) => {}
328 Ok(Err(err)) => tracing::error!(%err, "failed to write ses snapshot"),
329 Err(err) => tracing::error!(%err, "ses snapshot task panicked"),
330 }
331}
332
333type ResolvedAction = Option<(&'static str, Option<String>, Option<String>)>;
334
335#[async_trait]
336impl fakecloud_core::service::AwsService for SesV2Service {
337 fn service_name(&self) -> &str {
338 "ses"
339 }
340
341 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
342 if req.is_query_protocol {
344 let mutates = is_mutating_action(req.action.as_str());
345 let result = crate::v1::handle_v1_action(&self.state, &req);
346 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
347 self.save_snapshot().await;
348 }
349 return result;
350 }
351
352 let (action, resource_name, sub_resource) =
353 Self::resolve_action(&req).ok_or_else(|| {
354 let raw = req
365 .raw_path
366 .split_once('?')
367 .map(|(p, _)| p)
368 .unwrap_or(&req.raw_path);
369 let trimmed = raw.trim_start_matches('/');
370 let raw_segs: Vec<&str> = if trimmed.is_empty() {
371 Vec::new()
372 } else {
373 trimmed.split('/').collect()
374 };
375 let inside_ses = raw_segs.first().map(|s| *s == "v2").unwrap_or(false)
378 && raw_segs.get(1).map(|s| *s == "email").unwrap_or(false);
379 let label_problem = inside_ses
380 && (raw_segs.iter().any(|s| s.is_empty())
381 || raw_segs.iter().any(|seg| {
382 let decoded = percent_encoding::percent_decode_str(seg)
383 .decode_utf8_lossy()
384 .into_owned();
385 decoded.starts_with('{') && decoded.ends_with('}')
386 }));
387 if label_problem {
388 AwsServiceError::aws_error(
389 StatusCode::BAD_REQUEST,
390 "BadRequestException",
391 format!("Invalid request: {} {}", req.method, req.raw_path),
392 )
393 } else {
394 AwsServiceError::aws_error(
395 StatusCode::NOT_FOUND,
396 "UnknownOperationException",
397 format!("Unknown operation: {} {}", req.method, req.raw_path),
398 )
399 }
400 })?;
401
402 let res = resource_name.as_deref().unwrap_or("");
403 let sub = sub_resource.as_deref().unwrap_or("");
404 let mutates = is_mutating_action(action);
405
406 let result = match action {
407 "GetAccount" => self.get_account(&req),
408 "CreateEmailIdentity" => self.create_email_identity(&req),
409 "ListEmailIdentities" => self.list_email_identities(&req),
410 "GetEmailIdentity" => self.get_email_identity(res, &req),
411 "DeleteEmailIdentity" => self.delete_email_identity(res, &req),
412 "CreateConfigurationSet" => self.create_configuration_set(&req),
413 "ListConfigurationSets" => self.list_configuration_sets(&req),
414 "GetConfigurationSet" => self.get_configuration_set(res, &req),
415 "DeleteConfigurationSet" => self.delete_configuration_set(res, &req),
416 "CreateEmailTemplate" => self.create_email_template(&req),
417 "ListEmailTemplates" => self.list_email_templates(&req),
418 "GetEmailTemplate" => self.get_email_template(res, &req),
419 "UpdateEmailTemplate" => self.update_email_template(res, &req),
420 "DeleteEmailTemplate" => self.delete_email_template(res, &req),
421 "SendEmail" => self.send_email(&req),
422 "SendBulkEmail" => self.send_bulk_email(&req),
423 "TagResource" => self.tag_resource(&req),
424 "UntagResource" => self.untag_resource(&req),
425 "ListTagsForResource" => self.list_tags_for_resource(&req),
426 "CreateContactList" => self.create_contact_list(&req),
427 "GetContactList" => self.get_contact_list(res, &req),
428 "ListContactLists" => self.list_contact_lists(&req),
429 "UpdateContactList" => self.update_contact_list(res, &req),
430 "DeleteContactList" => self.delete_contact_list(res, &req),
431 "CreateContact" => self.create_contact(res, &req),
432 "GetContact" => self.get_contact(res, sub, &req),
433 "ListContacts" => self.list_contacts(res, &req),
434 "UpdateContact" => self.update_contact(res, sub, &req),
435 "DeleteContact" => self.delete_contact(res, sub, &req),
436 "PutSuppressedDestination" => self.put_suppressed_destination(&req),
437 "GetSuppressedDestination" => self.get_suppressed_destination(res, &req),
438 "DeleteSuppressedDestination" => self.delete_suppressed_destination(res, &req),
439 "ListSuppressedDestinations" => self.list_suppressed_destinations(&req),
440 "CreateConfigurationSetEventDestination" => {
441 self.create_configuration_set_event_destination(res, &req)
442 }
443 "GetConfigurationSetEventDestinations" => {
444 self.get_configuration_set_event_destinations(res, &req)
445 }
446 "UpdateConfigurationSetEventDestination" => {
447 self.update_configuration_set_event_destination(res, sub, &req)
448 }
449 "DeleteConfigurationSetEventDestination" => {
450 self.delete_configuration_set_event_destination(res, sub, &req)
451 }
452 "CreateEmailIdentityPolicy" => self.create_email_identity_policy(res, sub, &req),
453 "GetEmailIdentityPolicies" => self.get_email_identity_policies(res, &req),
454 "UpdateEmailIdentityPolicy" => self.update_email_identity_policy(res, sub, &req),
455 "DeleteEmailIdentityPolicy" => self.delete_email_identity_policy(res, sub, &req),
456 "PutEmailIdentityDkimAttributes" => self.put_email_identity_dkim_attributes(res, &req),
457 "PutEmailIdentityDkimSigningAttributes" => {
458 self.put_email_identity_dkim_signing_attributes(res, &req)
459 }
460 "PutEmailIdentityFeedbackAttributes" => {
461 self.put_email_identity_feedback_attributes(res, &req)
462 }
463 "PutEmailIdentityMailFromAttributes" => {
464 self.put_email_identity_mail_from_attributes(res, &req)
465 }
466 "PutEmailIdentityConfigurationSetAttributes" => {
467 self.put_email_identity_configuration_set_attributes(res, &req)
468 }
469 "PutConfigurationSetSendingOptions" => {
470 self.put_configuration_set_sending_options(res, &req)
471 }
472 "PutConfigurationSetDeliveryOptions" => {
473 self.put_configuration_set_delivery_options(res, &req)
474 }
475 "PutConfigurationSetTrackingOptions" => {
476 self.put_configuration_set_tracking_options(res, &req)
477 }
478 "PutConfigurationSetSuppressionOptions" => {
479 self.put_configuration_set_suppression_options(res, &req)
480 }
481 "PutConfigurationSetReputationOptions" => {
482 self.put_configuration_set_reputation_options(res, &req)
483 }
484 "PutConfigurationSetVdmOptions" => self.put_configuration_set_vdm_options(res, &req),
485 "PutConfigurationSetArchivingOptions" => {
486 self.put_configuration_set_archiving_options(res, &req)
487 }
488 "CreateCustomVerificationEmailTemplate" => {
489 self.create_custom_verification_email_template(&req)
490 }
491 "GetCustomVerificationEmailTemplate" => {
492 self.get_custom_verification_email_template(res, &req)
493 }
494 "ListCustomVerificationEmailTemplates" => {
495 self.list_custom_verification_email_templates(&req)
496 }
497 "UpdateCustomVerificationEmailTemplate" => {
498 self.update_custom_verification_email_template(res, &req)
499 }
500 "DeleteCustomVerificationEmailTemplate" => {
501 self.delete_custom_verification_email_template(res, &req)
502 }
503 "SendCustomVerificationEmail" => self.send_custom_verification_email(&req),
504 "TestRenderEmailTemplate" => self.test_render_email_template(res, &req),
505 "CreateDedicatedIpPool" => self.create_dedicated_ip_pool(&req),
506 "ListDedicatedIpPools" => self.list_dedicated_ip_pools(&req),
507 "DeleteDedicatedIpPool" => self.delete_dedicated_ip_pool(res, &req),
508 "GetDedicatedIp" => self.get_dedicated_ip(res, &req),
509 "GetDedicatedIps" => self.get_dedicated_ips(&req),
510 "PutDedicatedIpInPool" => self.put_dedicated_ip_in_pool(res, &req),
511 "PutDedicatedIpPoolScalingAttributes" => {
512 self.put_dedicated_ip_pool_scaling_attributes(res, &req)
513 }
514 "PutDedicatedIpWarmupAttributes" => self.put_dedicated_ip_warmup_attributes(res, &req),
515 "PutAccountDedicatedIpWarmupAttributes" => {
516 self.put_account_dedicated_ip_warmup_attributes(&req)
517 }
518 "CreateMultiRegionEndpoint" => self.create_multi_region_endpoint(&req),
519 "GetMultiRegionEndpoint" => self.get_multi_region_endpoint(res, &req),
520 "ListMultiRegionEndpoints" => self.list_multi_region_endpoints(&req),
521 "DeleteMultiRegionEndpoint" => self.delete_multi_region_endpoint(res, &req),
522 "PutAccountDetails" => self.put_account_details(&req),
523 "PutAccountSendingAttributes" => self.put_account_sending_attributes(&req),
524 "PutAccountSuppressionAttributes" => self.put_account_suppression_attributes(&req),
525 "PutAccountVdmAttributes" => self.put_account_vdm_attributes(&req),
526 "CreateImportJob" => self.create_import_job(&req),
527 "GetImportJob" => self.get_import_job(res, &req),
528 "ListImportJobs" => self.list_import_jobs(&req),
529 "CreateExportJob" => self.create_export_job(&req),
530 "GetExportJob" => self.get_export_job(res, &req),
531 "ListExportJobs" => self.list_export_jobs(&req),
532 "CancelExportJob" => self.cancel_export_job(res, &req),
533 "CreateTenant" => self.create_tenant(&req),
534 "PutTenantSuppressionAttributes" => self.put_tenant_suppression_attributes(&req),
535 "GetTenant" => self.get_tenant(&req),
536 "ListTenants" => self.list_tenants(&req),
537 "DeleteTenant" => self.delete_tenant(&req),
538 "CreateTenantResourceAssociation" => self.create_tenant_resource_association(&req),
539 "DeleteTenantResourceAssociation" => self.delete_tenant_resource_association(&req),
540 "ListTenantResources" => self.list_tenant_resources(&req),
541 "ListResourceTenants" => self.list_resource_tenants(&req),
542 "GetReputationEntity" => self.get_reputation_entity(res, sub, &req),
543 "ListReputationEntities" => self.list_reputation_entities(&req),
544 "UpdateReputationEntityCustomerManagedStatus" => {
545 self.update_reputation_entity_customer_managed_status(res, sub, &req)
546 }
547 "UpdateReputationEntityPolicy" => self.update_reputation_entity_policy(res, sub, &req),
548 "BatchGetMetricData" => self.batch_get_metric_data(&req),
549 "GetDedicatedIpPool" => self.get_dedicated_ip_pool(res, &req),
550 "GetDeliverabilityDashboardOptions" => self.get_deliverability_dashboard_options(&req),
551 "PutDeliverabilityDashboardOption" => self.put_deliverability_dashboard_option(&req),
552 "CreateDeliverabilityTestReport" => self.create_deliverability_test_report(&req),
553 "GetDeliverabilityTestReport" => self.get_deliverability_test_report(res, &req),
554 "ListDeliverabilityTestReports" => self.list_deliverability_test_reports(&req),
555 "GetBlacklistReports" => self.get_blacklist_reports(&req),
556 "GetDomainDeliverabilityCampaign" => self.get_domain_deliverability_campaign(res, &req),
557 "GetDomainStatisticsReport" => self.get_domain_statistics_report(res, &req),
558 "ListDomainDeliverabilityCampaigns" => {
559 self.list_domain_deliverability_campaigns(res, &req)
560 }
561 "GetEmailAddressInsights" => self.get_email_address_insights(&req),
562 "GetMessageInsights" => self.get_message_insights(res, &req),
563 "ListRecommendations" => self.list_recommendations(&req),
564 _ => Err(AwsServiceError::action_not_implemented("ses", action)),
565 };
566 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
567 self.save_snapshot().await;
568 }
569 result
570 }
571
572 fn supported_actions(&self) -> &[&str] {
573 &[
574 "GetAccount",
575 "CreateEmailIdentity",
576 "ListEmailIdentities",
577 "GetEmailIdentity",
578 "DeleteEmailIdentity",
579 "CreateConfigurationSet",
580 "ListConfigurationSets",
581 "GetConfigurationSet",
582 "DeleteConfigurationSet",
583 "CreateEmailTemplate",
584 "ListEmailTemplates",
585 "GetEmailTemplate",
586 "UpdateEmailTemplate",
587 "DeleteEmailTemplate",
588 "SendEmail",
589 "SendBulkEmail",
590 "TagResource",
591 "UntagResource",
592 "ListTagsForResource",
593 "CreateContactList",
594 "GetContactList",
595 "ListContactLists",
596 "UpdateContactList",
597 "DeleteContactList",
598 "CreateContact",
599 "GetContact",
600 "ListContacts",
601 "UpdateContact",
602 "DeleteContact",
603 "PutSuppressedDestination",
604 "GetSuppressedDestination",
605 "DeleteSuppressedDestination",
606 "ListSuppressedDestinations",
607 "CreateConfigurationSetEventDestination",
608 "GetConfigurationSetEventDestinations",
609 "UpdateConfigurationSetEventDestination",
610 "DeleteConfigurationSetEventDestination",
611 "CreateEmailIdentityPolicy",
612 "GetEmailIdentityPolicies",
613 "UpdateEmailIdentityPolicy",
614 "DeleteEmailIdentityPolicy",
615 "PutEmailIdentityDkimAttributes",
616 "PutEmailIdentityDkimSigningAttributes",
617 "PutEmailIdentityFeedbackAttributes",
618 "PutEmailIdentityMailFromAttributes",
619 "PutEmailIdentityConfigurationSetAttributes",
620 "PutConfigurationSetSendingOptions",
621 "PutConfigurationSetDeliveryOptions",
622 "PutConfigurationSetTrackingOptions",
623 "PutConfigurationSetSuppressionOptions",
624 "PutConfigurationSetReputationOptions",
625 "PutConfigurationSetVdmOptions",
626 "PutConfigurationSetArchivingOptions",
627 "CreateCustomVerificationEmailTemplate",
628 "GetCustomVerificationEmailTemplate",
629 "ListCustomVerificationEmailTemplates",
630 "UpdateCustomVerificationEmailTemplate",
631 "DeleteCustomVerificationEmailTemplate",
632 "SendCustomVerificationEmail",
633 "TestRenderEmailTemplate",
634 "CreateDedicatedIpPool",
635 "ListDedicatedIpPools",
636 "DeleteDedicatedIpPool",
637 "GetDedicatedIp",
638 "GetDedicatedIps",
639 "PutDedicatedIpInPool",
640 "PutDedicatedIpPoolScalingAttributes",
641 "PutDedicatedIpWarmupAttributes",
642 "PutAccountDedicatedIpWarmupAttributes",
643 "CreateMultiRegionEndpoint",
644 "GetMultiRegionEndpoint",
645 "ListMultiRegionEndpoints",
646 "DeleteMultiRegionEndpoint",
647 "PutAccountDetails",
648 "PutAccountSendingAttributes",
649 "PutAccountSuppressionAttributes",
650 "PutAccountVdmAttributes",
651 "CreateImportJob",
652 "GetImportJob",
653 "ListImportJobs",
654 "CreateExportJob",
655 "GetExportJob",
656 "ListExportJobs",
657 "CancelExportJob",
658 "CreateTenant",
659 "PutTenantSuppressionAttributes",
660 "GetTenant",
661 "ListTenants",
662 "DeleteTenant",
663 "CreateTenantResourceAssociation",
664 "DeleteTenantResourceAssociation",
665 "ListTenantResources",
666 "ListResourceTenants",
667 "GetReputationEntity",
668 "ListReputationEntities",
669 "UpdateReputationEntityCustomerManagedStatus",
670 "UpdateReputationEntityPolicy",
671 "BatchGetMetricData",
672 "GetDedicatedIpPool",
673 "GetDeliverabilityDashboardOptions",
674 "PutDeliverabilityDashboardOption",
675 "CreateDeliverabilityTestReport",
676 "GetDeliverabilityTestReport",
677 "ListDeliverabilityTestReports",
678 "GetBlacklistReports",
679 "GetDomainDeliverabilityCampaign",
680 "GetDomainStatisticsReport",
681 "ListDomainDeliverabilityCampaigns",
682 "GetEmailAddressInsights",
683 "GetMessageInsights",
684 "ListRecommendations",
685 ]
689 }
690}
691
692mod helpers;
693pub(crate) use helpers::*;
694
695#[cfg(test)]
696mod tests;