diff --git a/src/presentation/DigitalData.MessagingService.API/Controllers/OAuth2Controller.cs b/src/presentation/DigitalData.MessagingService.API/Controllers/OAuth2Controller.cs new file mode 100644 index 0000000..13a9a7d --- /dev/null +++ b/src/presentation/DigitalData.MessagingService.API/Controllers/OAuth2Controller.cs @@ -0,0 +1,95 @@ +using DigitalData.MessagingService.Application.OAuth2.Commands; +using DigitalData.MessagingService.Application.OAuth2.Queries; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace DigitalData.MessagingService.API.Controllers; + +/// +/// Manages the OAuth2 authorization code flow for email accounts. +/// Use these endpoints to authorize Google accounts without manually +/// obtaining refresh tokens via external tools. +/// +[ApiController] +[Route("api/[controller]")] +public class OAuth2Controller(IMediator mediator, IHttpContextAccessor httpContextAccessor) : ControllerBase +{ + #region Google Authorization Flow + + /// + /// Step 1: Redirects the user to Google's consent screen for the specified email account. + /// After consent, Google redirects to /api/oauth2/google/callback with an authorization code. + /// + /// ID of the email account to authorize. + /// Cancellation token. + /// HTTP 302 redirect to Google consent screen. + [HttpGet("google/authorize/{accountId:int}")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task AuthorizeGoogle(int accountId, CancellationToken cancellationToken) + { + var redirectUri = BuildCallbackUri(); + + var authUrl = await mediator.Send(new GetOAuth2AuthorizationUrlQuery + { + AccountId = accountId, + RedirectUri = redirectUri + }, cancellationToken); + + return Redirect(authUrl); + } + + /// + /// Step 2: Google callback endpoint. Exchanges the authorization code for a refresh token + /// and saves it to the email account. This endpoint is called automatically by Google + /// after the user grants consent — do not call it directly. + /// + /// Authorization code provided by Google. + /// Account ID passed as state in the authorization request. + /// Error message if the user denied access. + /// Cancellation token. + /// HTTP 200 on success, HTTP 400 if access was denied. + [HttpGet("google/callback")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task GoogleCallback( + [FromQuery] string? code, + [FromQuery] string? state, + [FromQuery] string? error, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(error)) + return BadRequest(new { Error = error, Message = "User denied access or an error occurred during Google OAuth2 authorization." }); + + if (string.IsNullOrWhiteSpace(code)) + return BadRequest(new { Error = "missing_code", Message = "Authorization code not received from Google." }); + + if (!int.TryParse(state, out var accountId)) + return BadRequest(new { Error = "invalid_state", Message = "Invalid state parameter — could not determine account ID." }); + + var redirectUri = BuildCallbackUri(); + + var result = await mediator.Send(new CompleteOAuth2AuthorizationCommand + { + AccountId = accountId, + Code = code, + RedirectUri = redirectUri + }, cancellationToken); + + return Ok(new + { + result.Success, + result.Username, + Message = $"Google OAuth2 authorization completed. Refresh token saved for account '{result.Username}'." + }); + } + + #endregion + + private string BuildCallbackUri() + { + var request = httpContextAccessor.HttpContext!.Request; + return $"{request.Scheme}://{request.Host}/api/oauth2/google/callback"; + } +} diff --git a/src/presentation/DigitalData.MessagingService.API/Controllers/SyncController.cs b/src/presentation/DigitalData.MessagingService.API/Controllers/SyncController.cs new file mode 100644 index 0000000..c5e59be --- /dev/null +++ b/src/presentation/DigitalData.MessagingService.API/Controllers/SyncController.cs @@ -0,0 +1,35 @@ +using DigitalData.MessagingService.Infrastructure.Services.Background; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; + +namespace DigitalData.MessagingService.API.Controllers; + +/// +/// Controls email synchronization operations. +/// +[ApiController] +[Route("api/[controller]")] +public class SyncController(IEnumerable hostedServices) : ControllerBase +{ + /// + /// Triggers an immediate email sync cycle for all configured accounts, + /// skipping the remaining interval wait. + /// If a sync is already in progress, the next cycle will start immediately after it completes. + /// + /// HTTP 202 Accepted. + [HttpPost("trigger")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public IActionResult TriggerSync() + { + var syncWorker = hostedServices.OfType().FirstOrDefault(); + + if (syncWorker is null) + return StatusCode(StatusCodes.Status503ServiceUnavailable, + new { Message = "EmailSyncWorker is not running." }); + + syncWorker.TriggerSync(); + + return Accepted(new { Message = "Sync triggered. The next cycle will start immediately." }); + } +} diff --git a/src/presentation/DigitalData.MessagingService.API/Program.cs b/src/presentation/DigitalData.MessagingService.API/Program.cs index e59f69c..2e37be9 100644 --- a/src/presentation/DigitalData.MessagingService.API/Program.cs +++ b/src/presentation/DigitalData.MessagingService.API/Program.cs @@ -71,6 +71,9 @@ try builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); + // Required by OAuth2Controller to build callback URIs + builder.Services.AddHttpContextAccessor(); + // Register Serilog.UI with SQLite provider for web log viewer builder.Services.AddSerilogUi(options => {