using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Security.Cryptography; using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; namespace JobTrackerApi.Services; public interface IGmailOAuthService { string BuildAuthorizationUrl(string ownerUserId, string redirectUri); string? ConsumeState(string state); Task ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken); Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken); Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); Task> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable queries, int maxResultsPerQuery, CancellationToken cancellationToken); Task> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable queries, int maxResultsPerQuery, CancellationToken cancellationToken); Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken); Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); } public sealed record GmailOAuthExchangeResult(string GmailAddress); public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet); public sealed record GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList MatchedQueries); public sealed record GmailMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GmailAttachmentId, bool Inline); public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList Labels, IReadOnlyList Attachments); internal sealed class GmailTokenResponse { public string? access_token { get; set; } public int expires_in { get; set; } public string? refresh_token { get; set; } public string? scope { get; set; } public string? token_type { get; set; } } public sealed class GmailOAuthService : IGmailOAuthService { private const string Scope = "openid email profile https://www.googleapis.com/auth/gmail.readonly"; private readonly IConfiguration _cfg; private readonly JobTrackerContext _db; private readonly IDataProtector _protector; private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; public GmailOAuthService( IConfiguration cfg, JobTrackerContext db, IDataProtectionProvider protectionProvider, IHttpClientFactory httpClientFactory, IMemoryCache cache) { _cfg = cfg; _db = db; _protector = protectionProvider.CreateProtector("gmail-oauth-tokens-v1"); _httpClientFactory = httpClientFactory; _cache = cache; } public string BuildAuthorizationUrl(string ownerUserId, string redirectUri) { var clientId = GetRequiredClientId(); var state = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) .Replace("+", "-") .Replace("/", "_") .TrimEnd('='); _cache.Set(GetStateCacheKey(state), ownerUserId, TimeSpan.FromMinutes(15)); var query = new Dictionary { ["client_id"] = clientId, ["redirect_uri"] = redirectUri, ["response_type"] = "code", ["access_type"] = "offline", ["prompt"] = "consent", ["include_granted_scopes"] = "true", ["scope"] = Scope, ["state"] = state, }; var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}")); return $"https://accounts.google.com/o/oauth2/v2/auth?{encoded}"; } public async Task ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken) { var tokens = await ExchangeCodeForTokensAsync(code, redirectUri, cancellationToken); var accessToken = tokens.access_token?.Trim(); var refreshToken = tokens.refresh_token?.Trim(); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Google did not return an access token."); if (string.IsNullOrWhiteSpace(refreshToken)) throw new InvalidOperationException("Google did not return a refresh token. Reconnect Gmail and ensure consent is granted."); var gmailAddress = await GetProfileAsync(accessToken, cancellationToken); if (string.IsNullOrWhiteSpace(gmailAddress)) throw new InvalidOperationException("Google did not return a Gmail address."); var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (existing is null) { existing = new GmailConnection { OwnerUserId = ownerUserId, }; _db.GmailConnections.Add(existing); } existing.GmailAddress = gmailAddress.Trim(); existing.EncryptedRefreshToken = _protector.Protect(refreshToken); existing.EncryptedAccessToken = _protector.Protect(accessToken); existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60)); existing.Scope = tokens.scope?.Trim() ?? Scope; existing.ConnectedAt = DateTimeOffset.UtcNow; existing.LastSyncStatus = "connected"; existing.LastSyncSource = "oauth-callback"; existing.LastSyncMode = "connect"; existing.LastSyncError = null; existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow; existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt; await _db.SaveChangesAsync(cancellationToken); return new GmailOAuthExchangeResult(existing.GmailAddress); } public string? ConsumeState(string state) { if (!_cache.TryGetValue(GetStateCacheKey(state), out var ownerUserId)) { return null; } _cache.Remove(GetStateCacheKey(state)); return ownerUserId; } public Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) { return _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); } public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken) { var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (existing is null) return; _db.GmailConnections.Remove(existing); await _db.SaveChangesAsync(cancellationToken); } public async Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) { maxResults = Math.Clamp(maxResults, 1, 25); try { var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken); var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}"; if (!string.IsNullOrWhiteSpace(query)) { url += $"&q={Uri.EscapeDataString(query.Trim())}"; } using var response = await client.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array) { await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken); return Array.Empty(); } var ids = messagesElement.EnumerateArray() .Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null) .Where(x => !string.IsNullOrWhiteSpace(x)) .Cast() .ToList(); var results = new List(ids.Count); foreach (var id in ids) { var detail = await GetMessageAsync(ownerUserId, id, cancellationToken); results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet)); } await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken); return results; } catch (Exception ex) { await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken); throw; } } public async Task> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable queries, int maxResultsPerQuery, CancellationToken cancellationToken) { var matchedMessages = await ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken); return matchedMessages.Select(static item => item.Message).ToList(); } public async Task> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable queries, int maxResultsPerQuery, CancellationToken cancellationToken) { maxResultsPerQuery = Math.Clamp(maxResultsPerQuery, 1, 25); var normalizedQueries = queries .Where(static query => !string.IsNullOrWhiteSpace(query)) .Select(static query => query.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (normalizedQueries.Count == 0) { return Array.Empty(); } var combined = new Dictionary Queries)>(StringComparer.Ordinal); foreach (var query in normalizedQueries) { var items = await ListMessagesAsync(ownerUserId, query, maxResultsPerQuery, cancellationToken); foreach (var item in items) { if (!combined.TryGetValue(item.Id, out var existing)) { combined[item.Id] = (item, new HashSet(StringComparer.OrdinalIgnoreCase) { query }); continue; } existing.Queries.Add(query); combined[item.Id] = existing; } } return combined.Values .Select(static entry => new GmailQueryMatchedMessage(entry.Message, entry.Queries.OrderBy(static query => query, StringComparer.OrdinalIgnoreCase).ToList())) .ToList(); } public async Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(threadId)) { return Array.Empty(); } try { var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken); var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date"; using var response = await client.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array) { await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken); return Array.Empty(); } var results = new List(); foreach (var messageElement in messagesElement.EnumerateArray()) { var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null; if (string.IsNullOrWhiteSpace(id)) continue; var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl) ? messageThreadIdEl.GetString() ?? threadId.Trim() : threadId.Trim(); var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty; var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default; var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary(StringComparer.OrdinalIgnoreCase); DateTimeOffset? date = null; if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate)) { date = parsedDate; } results.Add(new GmailMessageSummary( id.Trim(), messageThreadId, headers.TryGetValue("subject", out var subject) ? subject : string.Empty, headers.TryGetValue("from", out var from) ? from : string.Empty, headers.TryGetValue("to", out var to) ? to : string.Empty, date, snippet)); } await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken); return results; } catch (Exception ex) { await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", false, ex.Message, cancellationToken); throw; } } public async Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) { try { var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken); var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full"; using var response = await client.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); var root = doc.RootElement; var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : ""; var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : ""; var labels = root.TryGetProperty("labelIds", out var labelIdsEl) && labelIdsEl.ValueKind == JsonValueKind.Array ? labelIdsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast().ToList() : new List(); var payload = root.GetProperty("payload"); var headers = ReadHeaders(payload); var attachments = ReadAttachments(payload); var bodyText = ExtractBody(payload, "text/plain"); var bodyHtml = ExtractBody(payload, "text/html"); if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml)) { bodyText = StripHtml(bodyHtml); } else if (LooksLikeHtml(bodyText)) { bodyText = StripHtml(bodyText); } await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", true, null, cancellationToken); return new GmailMessageDetail( messageId, threadId, headers.TryGetValue("subject", out var subject) ? subject : "", headers.TryGetValue("from", out var from) ? from : "", headers.TryGetValue("to", out var to) ? to : "", headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null, snippet, bodyText.Trim(), string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml, labels, attachments ); } catch (Exception ex) { await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", false, ex.Message, cancellationToken); throw; } } private async Task GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken) { var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (connection is null) throw new InvalidOperationException("Gmail is not connected for this account."); if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) && connection.AccessTokenExpiresAt is { } expiresAt && expiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) { try { return _protector.Unprotect(connection.EncryptedAccessToken); } catch (CryptographicException) { connection.EncryptedAccessToken = null; connection.AccessTokenExpiresAt = null; } } string refreshToken; try { refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken); } catch (CryptographicException) { throw new InvalidOperationException("Your stored Gmail connection can no longer be decrypted after a server key change. Disconnect Gmail and connect it again."); } var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken); var accessToken = refreshed.access_token?.Trim(); if (string.IsNullOrWhiteSpace(accessToken)) throw new InvalidOperationException("Failed to refresh Gmail access token."); connection.EncryptedAccessToken = _protector.Protect(accessToken); connection.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(refreshed.expires_in - 60, 60)); if (!string.IsNullOrWhiteSpace(refreshed.scope)) { connection.Scope = refreshed.scope.Trim(); } await _db.SaveChangesAsync(cancellationToken); return accessToken; } private async Task ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); using var response = await client.PostAsync( "https://oauth2.googleapis.com/token", new FormUrlEncodedContent(new Dictionary { ["code"] = code, ["client_id"] = GetRequiredClientId(), ["client_secret"] = GetRequiredClientSecret(), ["redirect_uri"] = redirectUri, ["grant_type"] = "authorization_code", }), cancellationToken); var payload = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException($"Google token exchange failed: {payload}"); } return JsonSerializer.Deserialize(payload) ?? throw new InvalidOperationException("Unable to parse Google token response."); } private async Task RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); using var response = await client.PostAsync( "https://oauth2.googleapis.com/token", new FormUrlEncodedContent(new Dictionary { ["refresh_token"] = refreshToken, ["client_id"] = GetRequiredClientId(), ["client_secret"] = GetRequiredClientSecret(), ["grant_type"] = "refresh_token", }), cancellationToken); var payload = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException($"Google token refresh failed: {payload}"); } return JsonSerializer.Deserialize(payload) ?? throw new InvalidOperationException("Unable to parse Google refresh response."); } private async Task GetProfileAsync(string accessToken, CancellationToken cancellationToken) { var client = _httpClientFactory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); using var response = await client.GetAsync("https://gmail.googleapis.com/gmail/v1/users/me/profile", cancellationToken); var payload = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { throw new InvalidOperationException($"Failed to read Gmail profile: {payload}"); } using var doc = JsonDocument.Parse(payload); return doc.RootElement.TryGetProperty("emailAddress", out var emailEl) ? emailEl.GetString() ?? "" : ""; } private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken) { await TouchSyncStateAsync(ownerUserId, "sync", "gmail", true, null, cancellationToken); } private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken) { var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (connection is null) return; var now = DateTimeOffset.UtcNow; connection.LastSyncAttemptedAt = now; connection.LastSyncMode = mode; connection.LastSyncSource = source; connection.LastSyncStatus = succeeded ? "success" : "error"; connection.LastSyncError = succeeded ? null : TrimError(error); if (succeeded) { connection.LastSyncedAt = now; connection.LastSyncSucceededAt = now; } await _db.SaveChangesAsync(cancellationToken); } private static string? TrimError(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var trimmed = value.Trim(); return trimmed.Length <= 300 ? trimmed : trimmed[..300]; } private string GetRequiredClientId() { return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch { "" => throw new InvalidOperationException("Google Gmail client ID is not configured."), var value => value, }; } private string GetRequiredClientSecret() { return (_cfg["Google:GmailClientSecret"] ?? _cfg["Google:ClientSecret"] ?? "").Trim() switch { "" => throw new InvalidOperationException("Google Gmail client secret is not configured."), var value => value, }; } private static string GetStateCacheKey(string state) => $"gmail-oauth-state:{state}"; private static Dictionary ReadHeaders(JsonElement payload) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); if (!payload.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) { return result; } foreach (var item in headers.EnumerateArray()) { var name = item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null; var value = item.TryGetProperty("value", out var valueEl) ? valueEl.GetString() : null; if (string.IsNullOrWhiteSpace(name) || value is null) continue; result[name.Trim()] = value; } return result; } private static List ReadAttachments(JsonElement payload) { var results = new List(); ReadAttachmentsRecursive(payload, results); return results; } private static void ReadAttachmentsRecursive(JsonElement payload, List results) { var body = payload.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object ? bodyEl : default; var gmailAttachmentId = body.ValueKind == JsonValueKind.Object && body.TryGetProperty("attachmentId", out var attachmentIdEl) && attachmentIdEl.ValueKind == JsonValueKind.String ? attachmentIdEl.GetString() : null; var filename = payload.TryGetProperty("filename", out var filenameEl) ? filenameEl.GetString() : null; var mimeType = payload.TryGetProperty("mimeType", out var mimeTypeEl) ? mimeTypeEl.GetString() : null; var sizeBytes = body.ValueKind == JsonValueKind.Object && body.TryGetProperty("size", out var sizeEl) && sizeEl.ValueKind == JsonValueKind.Number ? sizeEl.GetInt64() : (long?)null; var disposition = payload.TryGetProperty("headers", out var headersEl) && headersEl.ValueKind == JsonValueKind.Array ? headersEl.EnumerateArray() .Where(h => h.TryGetProperty("name", out var n) && string.Equals(n.GetString(), "Content-Disposition", StringComparison.OrdinalIgnoreCase)) .Select(h => h.TryGetProperty("value", out var v) ? v.GetString() : null) .FirstOrDefault() : null; var isInline = !string.IsNullOrWhiteSpace(disposition) && disposition.Contains("inline", StringComparison.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(gmailAttachmentId) || !string.IsNullOrWhiteSpace(filename)) { results.Add(new GmailMessageAttachment(filename, mimeType, sizeBytes, gmailAttachmentId, isInline)); } if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array) { foreach (var part in partsEl.EnumerateArray()) { ReadAttachmentsRecursive(part, results); } } } private static string ExtractBody(JsonElement payload, string mimeType) { if (payload.TryGetProperty("mimeType", out var mimeTypeEl) && string.Equals(mimeTypeEl.GetString(), mimeType, StringComparison.OrdinalIgnoreCase) && payload.TryGetProperty("body", out var bodyEl) && bodyEl.TryGetProperty("data", out var dataEl)) { return DecodeBody(dataEl.GetString()); } if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array) { foreach (var part in partsEl.EnumerateArray()) { var nested = ExtractBody(part, mimeType); if (!string.IsNullOrWhiteSpace(nested)) { return nested; } } } return ""; } private static string DecodeBody(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return ""; var normalized = raw.Replace('-', '+').Replace('_', '/'); var padding = normalized.Length % 4; if (padding > 0) { normalized = normalized.PadRight(normalized.Length + (4 - padding), '='); } try { var bytes = Convert.FromBase64String(normalized); return Encoding.UTF8.GetString(bytes); } catch { return ""; } } private static bool LooksLikeHtml(string? value) { if (string.IsNullOrWhiteSpace(value)) return false; return value.Contains("", string.Empty) .Replace(" ", " ") .Replace("&", "&") .Trim(); } }