From bc7cbc14925bb751c18d48d9a6c539893d21392b Mon Sep 17 00:00:00 2001 From: wolf-demon Date: Wed, 16 Sep 2026 15:18:22 +0100 Subject: [PATCH] Add Google mailbox lifecycle controls and guarded sync recovery --- README.md | 2 + docs/mailboxes.md | 37 +++++++++++++ docs/migration.md | 9 +++- src/GuestOps.Api/AutoReplies.cs | 3 +- src/GuestOps.Api/Demo.cs | 1 + src/GuestOps.Api/GoogleMailbox.cs | 76 +++++++++++++++++++++----- src/GuestOps.Api/MailboxManagement.cs | 47 ++++++++++++++++ src/GuestOps.Api/Models.cs | 10 ++++ src/GuestOps.Api/Program.cs | 17 ++++-- src/GuestOps.Api/ReplyDelivery.cs | 3 +- src/GuestOps.Api/Store.cs | 10 ++-- src/GuestOps.Worker/Program.cs | 7 +-- tests/GuestOps.Tests/MailboxTests.cs | 78 +++++++++++++++++++++++++++ tests/GuestOps.Tests/Program.cs | 10 ++++ tests/GuestOps.Tests/TeamTests.cs | 1 + web/src/MailboxPanel.tsx | 21 ++++++++ web/src/ReplyActions.tsx | 2 +- web/src/api.ts | 2 +- web/src/main.tsx | 9 ++-- web/src/style.css | 2 + 20 files changed, 314 insertions(+), 33 deletions(-) create mode 100644 docs/mailboxes.md create mode 100644 src/GuestOps.Api/MailboxManagement.cs create mode 100644 tests/GuestOps.Tests/MailboxTests.cs create mode 100644 web/src/MailboxPanel.tsx diff --git a/README.md b/README.md index e344f51..bce7169 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A Linux-hosted hotel email workspace, developed separately from the Windows Gues - Owner-reviewed NMI invoice creation, tenant-specific merchant configuration and read-only status/recovery checks. Creation may email the customer a hosted payment link through NMI; it is off by default. - Controlled FAQ auto-replies: exact plain-text questions, owner-reviewed answers, test mode, daily quotas and thread/knowledge rechecks. Live mode defaults off. - Owner-issued staff invitations, assisted password recovery, session invalidation, disabled-account restoration and a hotel setup checklist. See [team access](docs/accounts.md). +- Google mailbox health, owner-only disconnect/reconnect and import restart, revoked-consent handling, retry delays and connection-bound delivery approvals. See [mailbox management](docs/mailboxes.md). - Docker image builds, private MongoDB configuration and an Nginx HTTPS example for the Debian sandbox. ## Explicit limits @@ -65,3 +66,4 @@ See [controlled FAQ automation](docs/auto-replies.md), [NMI payment setup and re + diff --git a/docs/mailboxes.md b/docs/mailboxes.md new file mode 100644 index 0000000..6942d6e --- /dev/null +++ b/docs/mailboxes.md @@ -0,0 +1,37 @@ +# Google mailbox management + +Owners manage Google connections in **Settings → Google mailbox**. Staff can see synchronization health but cannot disconnect, reconnect or restart imports. The panel refreshes every 30 seconds while visible and offers a manual status refresh. + +## Connection and recovery + +- **Connect Google mailbox** starts OAuth consent. Initial import covers seven days of inbox messages, in pages of up to 25 messages per worker cycle. +- **Reconnect Google** requires the same email address as the selected connection. Reconnection retains the mailbox ID and imported history, clears the page token and resumes the existing import window. It does not create a second copy of imported messages. +- **Restart import pass** clears the current page checkpoint without advancing the window. Use it for an import that needs another pass. It cannot bypass an active retry delay or repair revoked consent; those require waiting or reconnection respectively. +- **Disconnect from GuestOps** clears the stored encrypted refresh token, removes sending capability and stops new work once workers recheck the connection. Imported messages and drafts remain. Requests already in progress may finish. + +Disconnect is local to GuestOps. To revoke Google's authorization as well, use the Google account connections link shown after disconnection and remove GuestOps access. This is deliberately separate: revoking a shared Google app grant can affect other sessions. GuestOps does not claim that a local disconnect revokes provider consent. Google's [OAuth documentation](https://developers.google.com/identity/protocols/oauth2/web-server#tokenrevoke) describes revocation and account settings. + +An OAuth flow started before the most recent connection change cannot reactivate that old connection. Concurrent connection changes use version checks. A mailbox email stays assigned to its hotel even after disconnect; moving it to another hotel requires a separately reviewed administrator migration. + +## Health states + +The panel shows the last successful import page, last attempt, next scheduled attempt, whether additional pages remain, and a safe explanation. It never returns refresh tokens, provider page tokens or raw provider error bodies. + +- **Reconnect needed:** revoked/expired refresh access, rejected access authorization, or unreadable protected credentials. The import worker stops trying until reconnection. +- **Waiting for retry:** temporary network/provider failures and quota responses. Import retries use an increasing delay, starting at about one minute and capped at about one hour, with jitter. A provider Retry-After can extend this up to one day. +- **Configuration or permission failure:** directs the administrator to review the Google app configuration or authorization; subsequent checks are spaced about an hour apart. +- **Catching up:** another page remains in the current import window. The last successful time refers to a page, not proof that the entire inbox is current. + +Google's [Gmail error guidance](https://developers.google.com/workspace/gmail/api/guides/handle-errors) informs the error classification. A message returning 404 after listing is skipped. If Google rejects a saved pagination request with 400, GuestOps clears its page token, waits one minute and restarts the same window. Other malformed data can still require operator investigation; this is not a full mailbox repair or quarantine system. + +## Reply behavior across connection changes + +Each new delivery approval records the current mailbox connection identity. Disconnect/reconnect changes that identity. Earlier pending approvals fail before submission and need explicit staff review/retry; automatic FAQ replies can return to staff review. A final connection check also runs after token acquisition. Requests already submitted to Google cannot be recalled. An uncertain delivery remains held and is never blindly resent. + +Existing mailbox documents without a Version field remain compatible. Their connection identity defaults to empty until the next connection change. Sync updates require matching credentials, version and connected state, preventing an older worker from overwriting a disconnect or explicit checkpoint restart. + +## Acceptance before a hotel pilot + +Automated fixtures cover pagination, duplicate imports, deleted messages, invalid grants, throttling, client configuration failures, reconnect account matching, missing read scope, cross-hotel ownership, stale workers, interrupted connections and queued-reply protection. MongoDB and HTTP tests exercise persistence, legacy documents, owner-only controls and preview isolation. These tests do not contact Google. + +With a dedicated test mailbox on the HTTPS sandbox, verify consent and callback configuration, initial import, disconnect, manual removal of Google access, reconnect, read-only and send scopes, retained drafts, and staff review of rejected approvals. Keep FAQ live sending and other external writes off until their separate acceptance procedures pass. Full Gmail thread aggregation, history-repair tooling and attachments remain future work. diff --git a/docs/migration.md b/docs/migration.md index aa288f5..91fa23f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -14,7 +14,7 @@ The UI is an operational inbox rather than a port of the desktop booking grid. R Authentication uses ASP.NET cookie protection and its password hasher. Sessions expire after eight hours and validate the user's active status and role on each request. Owner provisioning remains an administrator CLI operation. Milestone 6 adds owner-issued staff invitations and assisted account recovery; transactional email recovery remains future work. -Gmail permissions are read-only. The worker fetches plain-text bodies and skips automated/list/bounce messages. It does not fetch attachments, mark messages read, delete them, send mail, or call a PMS. Initial import is seven days, 25 messages per worker cycle. Unfinished pages retain their checkpoint, and overlap between synchronization windows is deduplicated. Invalid provider pagination tokens currently require operator reconnection/reset of the mailbox checkpoint; there is no full history-repair UI yet. +Gmail permissions are read-only. The worker fetches plain-text bodies and skips automated/list/bounce messages. It does not fetch attachments, mark messages read, delete them, send mail, or call a PMS. Initial import is seven days, 25 messages per worker cycle. Unfinished pages retain their checkpoint, and overlap between synchronization windows is deduplicated. Milestone 7 adds guarded checkpoint restart and connection recovery; full history repair remains future work. ## Milestone 2: AI drafts and staff-approved delivery @@ -36,9 +36,13 @@ Implemented seven exact FAQ question rules, approved-answer version binding, tes Implemented owner-issued single-use invitation and recovery links, staff disable/restore controls, session invalidation after password changes, server-admin owner recovery and a setup checklist derived from saved hotel state. Links are copied and shared privately; GuestOps does not send recovery emails. See [account setup and limits](accounts.md). +## Milestone 7: Google mailbox lifecycle and recovery + +Implemented owner-only disconnect/reconnect and import restart controls, synchronization health, revoked-access recovery, retry delays, page recovery and connection-bound reply approvals. Local disconnect removes saved credentials; provider grant revocation is a separate Google account action. See [mailbox operation and acceptance](mailboxes.md). + ## Remaining milestones -1. Test Google connection with a dedicated test mailbox; add provider fixture tests, disconnect/revocation, refresh failure recovery and full thread aggregation. +1. Run dedicated Google test-mailbox acceptance, add full thread aggregation and history repair, and rehearse server backup/restore before the first hotel pilot. 2. Add verified transactional email for invitations and recovery notifications, MFA, granular roles and user preferences. 3. Run a representative live AI draft evaluation, improve retrieval and evidence presentation, and approve the selected provider data-processing arrangements. 4. Extend the implemented durable reply queue with operator recovery tooling and broaden the controlled FAQ rules only after live acceptance. Preserve the rule that uncertain sends are never blindly replayed. @@ -52,3 +56,4 @@ Target supplied by the owner: Debian 12, 4 CPU cores, 7.6 GiB RAM, 18 GiB free d + diff --git a/src/GuestOps.Api/AutoReplies.cs b/src/GuestOps.Api/AutoReplies.cs index c553bba..e0ad555 100644 --- a/src/GuestOps.Api/AutoReplies.cs +++ b/src/GuestOps.Api/AutoReplies.cs @@ -68,7 +68,7 @@ public sealed class AutoReplyWork(IStore store,IConfiguration config) else { var rule=(await store.Get(message.HotelId,result.RuleId))!; - message.Delivery=new Delivery{Automatic=true,AutoDay=DateTime.UtcNow.ToString("yyyy-MM-dd"),AutoRuleId=rule.Id,AutoRuleVersion=rule.Version,AutoKnowledgeId=rule.KnowledgeId,AutoKnowledgeVersion=rule.KnowledgeVersion,AutoEpoch=hotel.AutoReplyEpoch,Recipient=message.ReplyAddress,Body=result.Body+"\n\n"+hotel.Signature,ApprovedBy=rule.ApprovedBy}; + message.Delivery=new Delivery{MailboxEpoch=box!.ConnectionEpoch,Automatic=true,AutoDay=DateTime.UtcNow.ToString("yyyy-MM-dd"),AutoRuleId=rule.Id,AutoRuleVersion=rule.Version,AutoKnowledgeId=rule.KnowledgeId,AutoKnowledgeVersion=rule.KnowledgeVersion,AutoEpoch=hotel.AutoReplyEpoch,Recipient=message.ReplyAddress,Body=result.Body+"\n\n"+hotel.Signature,ApprovedBy=rule.ApprovedBy}; bool valid=await CanDeliver(store,config,message); try{_=ReplyMime.Build(message,box!);}catch{valid=false;} if(valid&&await Claim(message)){message.Draft=message.Delivery.Body;message.DraftSources=[result.KnowledgeId];message.AutoReplyDetail="Queued the approved FAQ answer for automatic delivery.";} @@ -102,3 +102,4 @@ public sealed class AutoReplyWork(IStore store,IConfiguration config) + diff --git a/src/GuestOps.Api/Demo.cs b/src/GuestOps.Api/Demo.cs index 88538b9..b066029 100644 --- a/src/GuestOps.Api/Demo.cs +++ b/src/GuestOps.Api/Demo.cs @@ -6,6 +6,7 @@ public static class Demo var hotel = new Hotel { Name = "The Willow House", Signature = "Warm regards,\nThe Willow House team" }; hotel.HotelId = hotel.Id; var user = new StaffUser { HotelId = hotel.Id, Name = "Alex Morgan", Email = hotel.Id + "@example.invalid" }; await store.Insert(hotel); await store.Insert(user); + await store.Insert(new Mailbox{HotelId=hotel.Id,Email="reservations@example.invalid",Status="NeedsReconnect",SyncErrorCode="ReconnectRequired",SyncError="Sample recovery state: a hotel owner would reconnect Google here. No real mailbox is connected.",LastSyncAt=DateTime.UtcNow.AddHours(-2),LastAttemptAt=DateTime.UtcNow.AddMinutes(-5)}); var questions = new[] { ("Emma Wilson", "A little question before our weekend stay", "Hello! We're looking forward to staying with you on Friday. Is there somewhere to park our car, and do we need to book a space?\n\nMany thanks,\nEmma", "Parking", "Hello Emma,\n\nWe're looking forward to welcoming you on Friday. Complimentary parking is available in our courtyard, subject to availability. There is no need to reserve a space.\n\nWarm regards,\nThe Willow House team"), ("James & Sophie", "Arriving a little earlier on Saturday", "Hi there, our train gets in at 12:30. Would it be possible to leave our bags with you before check-in? Thank you!", "Arrival", ""), diff --git a/src/GuestOps.Api/GoogleMailbox.cs b/src/GuestOps.Api/GoogleMailbox.cs index e1121a4..f1accd9 100644 --- a/src/GuestOps.Api/GoogleMailbox.cs +++ b/src/GuestOps.Api/GoogleMailbox.cs @@ -1,7 +1,15 @@ using System.Text; using System.Text.Json; +using System.Net; +using System.Security.Cryptography; using Microsoft.AspNetCore.DataProtection; namespace GuestOps.Web; +public sealed class GoogleFailure(string kind,HttpStatusCode status,TimeSpan? retryAfter=null):Exception("Google request failed: "+kind) +{ + public string Kind {get;}=kind; + public HttpStatusCode Status {get;}=status; + public TimeSpan? RetryAfter {get;}=retryAfter; +} public sealed class GoogleMailbox(HttpClient http, IConfiguration config, IStore store, IDataProtectionProvider protection) { @@ -18,44 +26,66 @@ public sealed class GoogleMailbox(HttpClient http, IConfiguration config, IStore { data["client_id"] = ClientId; data["client_secret"] = ClientSecret; using var response = await http.PostAsync("https://oauth2.googleapis.com/token", new FormUrlEncodedContent(data), ct); - response.EnsureSuccessStatusCode(); + await Check(response,true,ct); return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)).RootElement.Clone(); } async Task Read(string path, string token, CancellationToken ct = default) { using var request = new HttpRequestMessage(HttpMethod.Get, "https://gmail.googleapis.com/gmail/v1/users/me/" + path); request.Headers.Authorization = new("Bearer", token); - using var response = await http.SendAsync(request, ct); response.EnsureSuccessStatusCode(); + using var response = await http.SendAsync(request, ct); await Check(response,false,ct); return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)).RootElement.Clone(); } - public async Task Connect(string hotel, string code) + static async Task Check(HttpResponseMessage response,bool token,CancellationToken ct) + { + if(response.IsSuccessStatusCode)return; + var reason="";try{using var json=JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));if(json.RootElement.TryGetProperty("error",out var error)){if(error.ValueKind==JsonValueKind.String)reason=error.GetString()??"";else if(error.TryGetProperty("errors",out var errors)&&errors.GetArrayLength()>0&&errors[0].TryGetProperty("reason",out var value))reason=value.GetString()??"";}}catch(JsonException){} + var kind=token?(reason=="invalid_grant"?"ReconnectRequired":reason=="invalid_client"||response.StatusCode==HttpStatusCode.Unauthorized?"Configuration":"Temporary"): + response.StatusCode==HttpStatusCode.Unauthorized?"ReconnectRequired":response.StatusCode==HttpStatusCode.NotFound?"NotFound":response.StatusCode==HttpStatusCode.BadRequest?"BadRequest":response.StatusCode==HttpStatusCode.Forbidden&&reason is not ("rateLimitExceeded" or "userRateLimitExceeded")?"AccessDenied":"Temporary"; + var delay=response.Headers.RetryAfter?.Delta??(response.Headers.RetryAfter?.Date-DateTimeOffset.UtcNow); + throw new GoogleFailure(kind,response.StatusCode,delay); + } + public async Task Connect(string hotel, string code,DateTime? startedAt=null,string expectedEmail="") { if (string.IsNullOrWhiteSpace(code)) throw new InvalidOperationException("No authorization code."); var tokens = await Token(new() { ["code"] = code, ["redirect_uri"] = Callback, ["grant_type"] = "authorization_code" }); var profile = await Read("profile", tokens.GetProperty("access_token").GetString()!); var email = profile.GetProperty("emailAddress").GetString()!.ToLowerInvariant(); + if(!Input.Email(email)||expectedEmail.Length>0&&email!=expectedEmail)throw new InvalidOperationException("Choose the expected Google mailbox."); var prior = (await store.List(hotel)).SingleOrDefault(x => x.Email == email); + if(prior?.ConnectionChangedAt>startedAt.GetValueOrDefault(DateTime.MinValue))throw new MailboxConflict(); var mailbox = prior ?? new Mailbox { HotelId = hotel, Email = email }; + var version=mailbox.Version; // No token reuse across hotels. Mongo's unique mailbox-email index prevents // accidental connection of one shared mailbox to two hotel workspaces. mailbox.ProtectedRefreshToken = protector.Protect(tokens.GetProperty("refresh_token").GetString()!); mailbox.CanSend = tokens.TryGetProperty("scope", out var scopes) && scopes.GetString()!.Split(' ').Contains("https://www.googleapis.com/auth/gmail.send"); - mailbox.Status = "Connected"; mailbox.SyncError = ""; - await store.SaveMailbox(mailbox); + if(string.IsNullOrWhiteSpace(tokens.GetProperty("refresh_token").GetString()))throw new InvalidOperationException("Google did not return offline access."); + if(!tokens.TryGetProperty("scope",out var granted)||!granted.GetString()!.Split(' ').Contains("https://www.googleapis.com/auth/gmail.readonly"))throw new InvalidOperationException("Read permission is required."); + mailbox.Status = "Connected"; mailbox.SyncError = "";mailbox.SyncErrorCode="";mailbox.NextAttemptAt=null;mailbox.FailureCount=0;mailbox.PageToken=""; + mailbox.ConnectionEpoch=Guid.NewGuid().ToString("N");mailbox.ConnectionChangedAt=DateTime.UtcNow;mailbox.Version++; + if(!(prior==null?await store.TryInsertMailbox(mailbox):await store.Replace(hotel,mailbox.Id,version,mailbox)))throw new MailboxConflict(); } public async Task Sync(Mailbox mailbox, CancellationToken ct) { - var tokens = await Token(new() { ["refresh_token"] = protector.Unprotect(mailbox.ProtectedRefreshToken), ["grant_type"] = "refresh_token" }, ct); - var token = tokens.GetProperty("access_token").GetString()!; + if(mailbox.NextAttemptAt>DateTime.UtcNow||!await MailboxManagement.Current(store,mailbox))return; + mailbox.LastAttemptAt=DateTime.UtcNow;await store.SaveSync(mailbox); + var token = await AccessToken(mailbox,ct); var after = new DateTimeOffset(mailbox.WindowStart).ToUnixTimeSeconds(); var before = new DateTimeOffset(mailbox.WindowEnd).ToUnixTimeSeconds(); var query = Uri.EscapeDataString($"in:inbox after:{after} before:{before}"); var path = "messages?maxResults=25&q=" + query + (mailbox.PageToken.Length > 0 ? "&pageToken=" + Uri.EscapeDataString(mailbox.PageToken) : ""); - var page = await Read(path, token, ct); + JsonElement page; + try{page=await Read(path, token, ct);} + catch(GoogleFailure ex) when(ex.Status==HttpStatusCode.BadRequest&&mailbox.PageToken.Length>0) + { + mailbox.PageToken="";mailbox.SyncErrorCode="CheckpointRestart";mailbox.SyncError="Google rejected the saved page. The worker will restart this import window without duplicating messages.";mailbox.NextAttemptAt=DateTime.UtcNow.AddMinutes(1);await store.SaveSync(mailbox);return; + } if (page.TryGetProperty("messages", out var items)) foreach (var item in items.EnumerateArray()) { + if(!await MailboxManagement.Current(store,mailbox))return; var id = item.GetProperty("id").GetString()!; - var message = await Read("messages/" + Uri.EscapeDataString(id) + "?format=full", token, ct); + JsonElement message;try{message=await Read("messages/" + Uri.EscapeDataString(id) + "?format=full", token, ct);}catch(GoogleFailure ex) when(ex.Status==HttpStatusCode.NotFound){continue;} var payload = message.GetProperty("payload"); string Header(string name) => payload.GetProperty("headers").EnumerateArray().Where(x => string.Equals(x.GetProperty("name").GetString(), name, StringComparison.OrdinalIgnoreCase)).Select(x => x.GetProperty("value").GetString()).FirstOrDefault() ?? ""; var auto = Header("Auto-Submitted"); @@ -65,11 +95,12 @@ public sealed class GoogleMailbox(HttpClient http, IConfiguration config, IStore row.ReplyAddress = ReplyMime.Address(Header("Reply-To").Length > 0 ? Header("Reply-To") : Header("From")); row.RfcMessageId = Header("Message-ID"); row.AutoReplyHeadersEligible = FaqMatcher.HeadersEligible(payload,mailbox.Email); + if(!await MailboxManagement.Current(store,mailbox))return; await store.Import(row); // deduplicated before advancing the page checkpoint } mailbox.PageToken = page.TryGetProperty("nextPageToken", out var next) ? next.GetString()! : ""; if (mailbox.PageToken.Length == 0) { mailbox.WindowStart = mailbox.WindowEnd.AddMinutes(-5); mailbox.WindowEnd = DateTime.UtcNow; } - mailbox.LastSyncAt = DateTime.UtcNow; mailbox.SyncError = ""; + mailbox.LastSyncAt = DateTime.UtcNow; mailbox.SyncError = "";mailbox.SyncErrorCode="";mailbox.FailureCount=0;mailbox.NextAttemptAt=null; await store.SaveSync(mailbox); } static string PlainText(JsonElement payload) @@ -83,8 +114,29 @@ public sealed class GoogleMailbox(HttpClient http, IConfiguration config, IStore } public async Task AccessToken(Mailbox mailbox, CancellationToken ct) { - var token = await Token(new() { ["refresh_token"] = protector.Unprotect(mailbox.ProtectedRefreshToken), ["grant_type"] = "refresh_token" }, ct); - return token.GetProperty("access_token").GetString()!; + if(!await MailboxManagement.Current(store,mailbox))throw new MailboxConflict(); + try + { + var token = await Token(new() { ["refresh_token"] = protector.Unprotect(mailbox.ProtectedRefreshToken), ["grant_type"] = "refresh_token" }, ct); + return token.GetProperty("access_token").GetString()!; + } + catch(Exception ex) when(ex is CryptographicException || ex is GoogleFailure {Kind:"ReconnectRequired"}) {await RecordFailure(mailbox,ex);throw;} + } + public async Task RecordFailure(Mailbox mailbox,Exception ex) + { + if(ex is MailboxConflict)return; + mailbox.LastAttemptAt=DateTime.UtcNow;mailbox.FailureCount=Math.Min(mailbox.FailureCount+1,20); + var kind=ex is GoogleFailure failure?failure.Kind:ex is CryptographicException?"ReconnectRequired":"Temporary"; + mailbox.SyncErrorCode=kind; + if(kind=="ReconnectRequired") {mailbox.Status="NeedsReconnect";mailbox.NextAttemptAt=null;mailbox.SyncError="Google access is unavailable. Ask the hotel owner to reconnect this mailbox.";} + else + { + var seconds=kind is "Configuration" or "AccessDenied"?3600:Math.Min(3600,60*Math.Pow(2,mailbox.FailureCount-1)); + if(ex is GoogleFailure g&&g.RetryAfter is {} delay)seconds=Math.Max(seconds,Math.Min(86400,delay.TotalSeconds)); + mailbox.NextAttemptAt=DateTime.UtcNow.AddSeconds(seconds+Random.Shared.Next(0,31)); + mailbox.SyncError=kind is "Configuration" or "AccessDenied"?"Google rejected the app configuration or permissions. Ask the administrator to review access. A later check is scheduled.":"Google synchronization is temporarily unavailable. The worker will retry automatically."; + } + await store.SaveSync(mailbox); } public async Task AutoReplyThreadUnchanged(Conversation message,string token,CancellationToken ct) { diff --git a/src/GuestOps.Api/MailboxManagement.cs b/src/GuestOps.Api/MailboxManagement.cs new file mode 100644 index 0000000..6ce32e8 --- /dev/null +++ b/src/GuestOps.Api/MailboxManagement.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +namespace GuestOps.Web; +public sealed class MailboxConflict():Exception("The mailbox changed. Refresh its status and try again."); +public static class MailboxManagement +{ + public static object View(Mailbox box)=>new {box.Id,box.Email,box.Status,box.Version,box.LastSyncAt,box.LastAttemptAt,box.NextAttemptAt,box.FailureCount,box.SyncError,box.SyncErrorCode,box.CanSend,catchingUp=box.PageToken.Length>0}; + public static async Task Current(IStore store,Mailbox box) + { + var current=await store.Get(box.HotelId,box.Id);return current?.Status=="Connected"&¤t.Version==box.Version&¤t.ProtectedRefreshToken==box.ProtectedRefreshToken; + } + public static async Task Change(IStore store,Mailbox box,long version,string action) + { + if(box.Version!=version)return false; + if(action=="disconnect") + { + if(box.Status=="Disconnected")return false; + box.Status="Disconnected";box.ProtectedRefreshToken="";box.CanSend=false;box.ConnectionEpoch=Guid.NewGuid().ToString("N");box.ConnectionChangedAt=DateTime.UtcNow;box.SyncError="Disconnected from GuestOps. Reconnect to resume.";box.SyncErrorCode="Disconnected";box.NextAttemptAt=null; + } + else if(action=="retry") + { + if(box.Status!="Connected"||box.NextAttemptAt>DateTime.UtcNow)return false; + // Retry the same window from its first page; duplicate imports remain idempotent. + box.PageToken="";box.NextAttemptAt=null;box.SyncError="A fresh import pass is queued for the worker.";box.SyncErrorCode="RestartQueued"; + } + else return false; + box.Version++;return await store.Replace(box.HotelId,box.Id,version,box); + } + public static void Map(RouteGroupBuilder api,bool preview) + { + api.MapPost("/mailboxes/{id}/{action}",async(string id,string action,VersionInput input,HttpContext c,IStore store,GoogleMailbox google)=> + { + var box=await store.Get(Session.Hotel(c),id);if(box==null)return Results.NotFound(); + if(preview)return Results.BadRequest(new{error="Real mailbox changes are unavailable in preview."}); + if(action=="reconnect") + { + if(!google.Configured)return Results.BadRequest(new{error="Ask the administrator to configure Google first."}); + if(box.Version!=input.Version)return Input.Conflict(); + var state=new OAuthRequest{Id=Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)),HotelId=box.HotelId,UserId=c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,ExpectedEmail=box.Email,ExpiresAt=DateTime.UtcNow.AddMinutes(10)}; + await store.Insert(state);return Results.Ok(new{url=google.AuthorizationUrl(state.Id)}); + } + if(action is not ("disconnect" or "retry"))return Results.NotFound(); + if(!await Change(store,box,input.Version,action))return Results.Conflict(new{error="The mailbox changed, needs reconnection, or is waiting for its retry time. Refresh the status."}); + await Session.Audit(store,c,action=="disconnect"?"Disconnected Google mailbox from GuestOps":"Requested a fresh mailbox import pass");return Results.Ok(View(box)); + }).RequireAuthorization("Owner").RequireRateLimiting("accounts"); + } +} + diff --git a/src/GuestOps.Api/Models.cs b/src/GuestOps.Api/Models.cs index d042b37..c1e99a4 100644 --- a/src/GuestOps.Api/Models.cs +++ b/src/GuestOps.Api/Models.cs @@ -71,6 +71,13 @@ public class Conversation : TenantDocument } public class Mailbox : TenantDocument { + public long Version {get;set;} + public string ConnectionEpoch {get;set;}=""; + public DateTime? ConnectionChangedAt {get;set;} + public DateTime? LastAttemptAt {get;set;} + public DateTime? NextAttemptAt {get;set;} + public int FailureCount {get;set;} + public string SyncErrorCode {get;set;}=""; public bool CanSend { get; set; } public string Email { get; set; } = ""; public string ProtectedRefreshToken { get; set; } = ""; @@ -83,6 +90,8 @@ public class Mailbox : TenantDocument } public class OAuthRequest : TenantDocument { + public DateTime StartedAt {get;set;}=DateTime.UtcNow; + public string ExpectedEmail {get;set;}=""; public string UserId { get; set; } = ""; public DateTime ExpiresAt { get; set; } } @@ -108,6 +117,7 @@ public record SendInput(long Version, string Recipient); public record ReplyControlsInput(long Version, bool AiDraftsEnabled, bool StaffSendingEnabled); public class Delivery { + public string MailboxEpoch {get;set;}=""; public bool Automatic {get;set;} public string AutoRuleId {get;set;}=""; public long AutoRuleVersion {get;set;} diff --git a/src/GuestOps.Api/Program.cs b/src/GuestOps.Api/Program.cs index 7c81c5a..2d551fe 100644 --- a/src/GuestOps.Api/Program.cs +++ b/src/GuestOps.Api/Program.cs @@ -101,6 +101,8 @@ app.Use(async (ctx, next) => ctx.Response.Headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; if (ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/account")) ctx.Response.Headers.CacheControl = "no-store"; try { await next(); } + catch (MailboxConflict ex) { ctx.Response.StatusCode = 409; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } + catch (GoogleFailure) { ctx.Response.StatusCode = 503; await ctx.Response.WriteAsJsonAsync(new { error = "Google could not complete this check. Review the mailbox status in Settings. No resend has been queued." }); } catch (AccountInvalid ex) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } catch (AccountConflict ex) { ctx.Response.StatusCode = 409; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } catch (PaymentInvalid ex) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } @@ -144,7 +146,8 @@ var api = app.MapGroup("/api").RequireAuthorization(); PmsEndpoints.Map(api,preview); PaymentEndpoints.Map(api,preview); AutoReplyEndpoints.Map(api,preview); -TeamEndpoints.Map(app,api,preview); +TeamEndpoints.Map(app,api,preview); +MailboxManagement.Map(api,preview); api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get(Session.Hotel(c), Session.Hotel(c)))); api.MapPut("/hotel", async (SettingsInput input, HttpContext c) => { @@ -191,7 +194,7 @@ api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContex await Session.Audit(store, c, "Updated hotel knowledge"); return Results.Ok(item); }).RequireAuthorization("Owner"); api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100))); -api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, items = (await store.List(Session.Hotel(c))).Select(x => new { x.Id, x.Email, x.Status, x.LastSyncAt, x.SyncError, x.CanSend }) })); +api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, items = (await store.List(Session.Hotel(c))).Select(MailboxManagement.View) })); api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, GoogleMailbox google, AiDrafts ai) => { if ((input.AiDraftsEnabled && (preview || !ai.Configured)) || (input.StaffSendingEnabled && (preview || !google.SendingConfigured))) return Results.BadRequest(new { error = "The administrator must configure this capability first." }); @@ -225,10 +228,10 @@ api.MapPost("/conversations/{id}/send", async (string id, SendInput input, HttpC var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); if (item.Delivery != null) return Results.Conflict(new { error = "This message already has a delivery request. Refresh to see its status." }); var hotel = await store.Get(item.HotelId, item.HotelId); var mailbox = await store.Get(item.HotelId, item.MailboxId); - if (preview || hotel?.StaffSendingEnabled != true || !google.SendingConfigured || mailbox?.CanSend != true) return Results.BadRequest(new { error = "Enable staff sending and reconnect Google with send permission first." }); + if (preview || hotel?.StaffSendingEnabled != true || !google.SendingConfigured || mailbox?.CanSend != true || mailbox.Status != "Connected") return Results.BadRequest(new { error = "Enable staff sending and reconnect Google with send permission first." }); if (item.Version != input.Version) return Input.Conflict(); if (input.Recipient != item.ReplyAddress || string.IsNullOrWhiteSpace(item.ReplyAddress)) return Results.BadRequest(new { error = "The recipient must match the message's reply address." }); - item.Delivery = new Delivery { Recipient = item.ReplyAddress, Body = item.Draft, ApprovedBy = c.User.FindFirstValue(ClaimTypes.NameIdentifier)! }; + item.Delivery = new Delivery { MailboxEpoch=mailbox.ConnectionEpoch, Recipient = item.ReplyAddress, Body = item.Draft, ApprovedBy = c.User.FindFirstValue(ClaimTypes.NameIdentifier)! }; try { _ = ReplyMime.Build(item, mailbox); } catch { return Results.BadRequest(new { error = "This message lacks valid reply metadata or a saved draft. Reply in Gmail instead." }); } item.Version++; if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict(); @@ -244,6 +247,9 @@ api.MapPost("/conversations/{id}/delivery/retry", async (string id, VersionInput { var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); if (preview || item.Delivery?.State != "Rejected" || item.Version != input.Version) return Input.Conflict(); + var retryMailbox=await store.Get(item.HotelId,item.MailboxId); + if(retryMailbox?.Status!="Connected"||!retryMailbox.CanSend)return Results.BadRequest(new{error="Reconnect the mailbox with sending permission first."}); + item.Delivery.MailboxEpoch=retryMailbox.ConnectionEpoch; item.Delivery.State = "Pending"; item.Delivery.UpdatedAt = DateTime.UtcNow; item.Version++; if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, "Retried a reply that had not reached Gmail sending"); return Results.Ok(item); @@ -271,7 +277,7 @@ api.MapGet("/integrations/google/callback", async (HttpContext c, GoogleMailbox var state = await store.ConsumeOAuth(c.Request.Query["state"].ToString(), Session.Hotel(c), c.User.FindFirstValue(ClaimTypes.NameIdentifier)!); if (state == null) return Results.BadRequest(new { error = "The connection request expired. Start again from Settings." }); if (c.Request.Query.ContainsKey("error")) return Results.Redirect("/settings?google=cancelled"); - try { await google.Connect(Session.Hotel(c), c.Request.Query["code"].ToString()); await Session.Audit(store, c, "Connected Google mailbox"); } + try { await google.Connect(Session.Hotel(c), c.Request.Query["code"].ToString(),state.StartedAt,state.ExpectedEmail); await Session.Audit(store, c, "Connected Google mailbox"); } catch { return Results.Redirect("/settings?google=failed"); } return Results.Redirect("/settings?google=connected"); }).RequireAuthorization("Owner"); @@ -298,3 +304,4 @@ namespace GuestOps.Web } + diff --git a/src/GuestOps.Api/ReplyDelivery.cs b/src/GuestOps.Api/ReplyDelivery.cs index bf98853..cddff5c 100644 --- a/src/GuestOps.Api/ReplyDelivery.cs +++ b/src/GuestOps.Api/ReplyDelivery.cs @@ -48,11 +48,12 @@ public sealed class ReplyDelivery(IStore store, GoogleMailbox google, IConfigura { var hotel = await store.Get(message.HotelId, message.HotelId); var mailbox = await store.Get(message.HotelId, message.MailboxId); - if (hotel?.StaffSendingEnabled != true || mailbox?.CanSend != true || mailbox.Status != "Connected" || !google.SendingConfigured) throw new InvalidOperationException("Sending disabled."); + if (hotel?.StaffSendingEnabled != true || mailbox?.CanSend != true || mailbox.Status != "Connected" || mailbox.ConnectionEpoch!=message.Delivery.MailboxEpoch || !google.SendingConfigured) throw new InvalidOperationException("Sending disabled or mailbox connection changed."); if(!await AutoReplyWork.CanDeliver(store,config,message))throw new InvalidOperationException("Automatic approval no longer valid."); raw = ReplyMime.Build(message, mailbox); token = await google.AccessToken(mailbox, ct); if(message.Delivery.Automatic&&(!await google.AutoReplyThreadUnchanged(message,token,ct)||!await AutoReplyWork.CanDeliver(store,config,message)))throw new InvalidOperationException("Automatic reply no longer eligible."); + if(!await MailboxManagement.Current(store,mailbox))throw new MailboxConflict(); ct.ThrowIfCancellationRequested(); } catch { await Save("Rejected", "Nothing was sent. Check reply metadata, hotel controls and Google connection, then retry the approved reply."); return; } diff --git a/src/GuestOps.Api/Store.cs b/src/GuestOps.Api/Store.cs index 4a4b50e..87d3562 100644 --- a/src/GuestOps.Api/Store.cs +++ b/src/GuestOps.Api/Store.cs @@ -19,6 +19,7 @@ public interface IStore Task ConsumeOAuth(string id, string hotel, string user); Task> Mailboxes(); Task SaveMailbox(Mailbox mailbox); + Task TryInsertMailbox(Mailbox mailbox); Task SaveSync(Mailbox mailbox); Task TryLease(string id, string owner); Task ReleaseLease(string id, string owner); @@ -81,7 +82,7 @@ public sealed class MongoStore : IStore { if (document.HotelId != hotel || document.Id != id) throw new InvalidOperationException("Invalid document scope."); var versionFilter=Builders.Filter.Eq("Version",version); - if(typeof(T)==typeof(StaffUser)&&version==0)versionFilter|=Builders.Filter.Exists("Version",false); + if((typeof(T)==typeof(StaffUser)||typeof(T)==typeof(Mailbox))&&version==0)versionFilter|=Builders.Filter.Exists("Version",false); var result = await Collection().ReplaceOneAsync(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id) & versionFilter, document); return result.ModifiedCount == 1; } @@ -97,9 +98,11 @@ public sealed class MongoStore : IStore public async Task ConsumeOAuth(string id, string hotel, string user) => await Collection().FindOneAndDeleteAsync(x => x.Id == id && x.HotelId == hotel && x.UserId == user && x.ExpiresAt > DateTime.UtcNow); public Task> Mailboxes() => Collection().Find(x => x.Status == "Connected").ToListAsync(); public async Task SaveMailbox(Mailbox mailbox) => await Collection().ReplaceOneAsync(x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id, mailbox, new ReplaceOptions { IsUpsert = true }); + public async Task TryInsertMailbox(Mailbox mailbox){try{await Insert(mailbox);return true;}catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return false;}} public async Task SaveSync(Mailbox mailbox) => await Collection().UpdateOneAsync( - x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id && x.ProtectedRefreshToken == mailbox.ProtectedRefreshToken, + Builders.Filter.Eq(x=>x.HotelId,mailbox.HotelId)&Builders.Filter.Eq(x=>x.Id,mailbox.Id)&Builders.Filter.Eq(x=>x.ProtectedRefreshToken,mailbox.ProtectedRefreshToken)&Builders.Filter.Eq(x=>x.Status,"Connected")&(mailbox.Version==0?(Builders.Filter.Eq(x=>x.Version,0)|Builders.Filter.Exists("Version",false)):Builders.Filter.Eq(x=>x.Version,mailbox.Version)), Builders.Update.Set(x => x.LastSyncAt, mailbox.LastSyncAt).Set(x => x.SyncError, mailbox.SyncError) + .Set(x=>x.Status,mailbox.Status).Set(x=>x.LastAttemptAt,mailbox.LastAttemptAt).Set(x=>x.NextAttemptAt,mailbox.NextAttemptAt).Set(x=>x.FailureCount,mailbox.FailureCount).Set(x=>x.SyncErrorCode,mailbox.SyncErrorCode) .Set(x => x.PageToken, mailbox.PageToken).Set(x => x.WindowStart, mailbox.WindowStart).Set(x => x.WindowEnd, mailbox.WindowEnd)); public async Task TryLease(string id, string owner) { @@ -190,7 +193,8 @@ public sealed class PreviewStore : IStore public Task ConsumeOAuth(string id, string hotel, string user) { lock(gate) { var x = rows.TryGetValue(Key(id), out var raw) ? Clone(raw) : null; if(x?.HotelId != hotel || x.UserId != user || x.ExpiresAt <= DateTime.UtcNow) return Task.FromResult(null); rows.TryRemove(Key(id),out _); return Task.FromResult(x); } } public Task> Mailboxes() => Task.FromResult(new List()); public Task SaveMailbox(Mailbox mailbox) => throw new InvalidOperationException("Real mailbox connections are unavailable in preview mode."); - public Task SaveSync(Mailbox mailbox) => throw new InvalidOperationException("Real mailbox connections are unavailable in preview mode."); + public Task TryInsertMailbox(Mailbox mailbox){lock(gate){if(rows.Where(x=>x.Key.StartsWith("Mailbox:")).Select(x=>Clone(x.Value)).Any(x=>x.Email==mailbox.Email))return Task.FromResult(false);return Task.FromResult(rows.TryAdd(Key(mailbox.Id),Json(mailbox)));}} + public Task SaveSync(Mailbox mailbox){lock(gate){if(rows.TryGetValue(Key(mailbox.Id),out var raw)){var old=Clone(raw);if(old.HotelId==mailbox.HotelId&&old.Version==mailbox.Version&&old.Status=="Connected"&&old.ProtectedRefreshToken==mailbox.ProtectedRefreshToken)rows[Key(mailbox.Id)]=Json(mailbox);}return Task.CompletedTask;}} public Task TryLease(string id, string owner) => Task.FromResult(false); public Task ReleaseLease(string id, string owner) => Task.CompletedTask; public async Task Import(Conversation message) { if (!(await List(message.HotelId)).Any(x => x.MailboxId == message.MailboxId && x.ProviderMessageId == message.ProviderMessageId)) await Insert(message); } diff --git a/src/GuestOps.Worker/Program.cs b/src/GuestOps.Worker/Program.cs index 75b86ed..70f9311 100644 --- a/src/GuestOps.Worker/Program.cs +++ b/src/GuestOps.Worker/Program.cs @@ -31,7 +31,7 @@ sealed class MailboxWorker(IStore store, IServiceScopeFactory factory, ILogger DateTime.UtcNow || !await store.TryLease(mailbox.Id, owner)) continue; // Per-page deadline is shorter than the lease. Import is idempotent. using var deadline = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); deadline.CancelAfter(TimeSpan.FromMinutes(2)); try { using var scope = factory.CreateScope(); await scope.ServiceProvider.GetRequiredService().Sync(mailbox, deadline.Token); } @@ -39,8 +39,8 @@ sealed class MailboxWorker(IStore store, IServiceScopeFactory factory, ILogger().RecordFailure(mailbox,ex); } finally { await store.ReleaseLease(mailbox.Id, owner); } } @@ -95,3 +95,4 @@ sealed class AutoReplyWorker(IStore store,IServiceScopeFactory factory,ILogger check,IStore store) + { + var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary{{"Google:ClientId","fixture-client"},{"Google:ClientSecret","fixture-secret"},{"Google:EnableSending","true"}}).Build(); + var protection=new EphemeralDataProtectionProvider();var protector=protection.CreateProtector("GoogleMailbox.refresh.v1"); + var fixture=new Fixture();var google=new GoogleMailbox(new HttpClient(fixture),config,store,protection); + var hotel=new Hotel{StaffSendingEnabled=true};hotel.HotelId=hotel.Id;await store.Insert(hotel); + async Task NewBox(){var box=new Mailbox{HotelId=hotel.Id,Email=Guid.NewGuid().ToString("N")+"@example.invalid",ProtectedRefreshToken=protector.Protect("fixture-refresh"),CanSend=true};await store.Insert(box);fixture.Email=box.Email;return box;} + async Task Reload(Mailbox box)=>(await store.Get(box.HotelId,box.Id))!; + async Task Failure(Mailbox box){try{await google.Sync(box,default);}catch(Exception ex){await google.RecordFailure(box,ex);}} + var box=await NewBox();fixture.NextPage=true;await google.Sync(box,default);var saved=await Reload(box); + check("Gmail import skips messages deleted after listing",(await store.List(hotel.Id)).Count==1); + check("Gmail page checkpoint and health persist",saved.PageToken=="fixture-next"&&saved.LastSyncAt!=null&&saved.LastAttemptAt!=null); + fixture.NextPage=false;await google.Sync(saved,default);saved=await Reload(box); + check("Replayed Gmail page does not duplicate imported messages",(await store.List(hotel.Id)).Count==1&&saved.PageToken==""); + box=await NewBox();box.PageToken="expired-page";box.Version++;await store.Replace(box.HotelId,box.Id,0,box);var start=box.WindowStart;fixture.BadPage=true;await google.Sync(box,default);saved=await Reload(box); + check("Rejected Gmail page restarts same window without advancing",saved.PageToken==""&&Math.Abs((saved.WindowStart-start).TotalMilliseconds)<1&&saved.SyncErrorCode=="CheckpointRestart"&&saved.NextAttemptAt>DateTime.UtcNow);fixture.BadPage=false; + box=await NewBox();fixture.TokenError="invalid_grant";await Failure(box);saved=await Reload(box); + check("Revoked Google refresh token requires reconnection",saved.Status=="NeedsReconnect"&&saved.SyncErrorCode=="ReconnectRequired");var requests=fixture.Requests;await google.Sync(saved,default); + check("Revoked connection does not keep requesting Google tokens",fixture.Requests==requests);fixture.TokenError=""; + box=await NewBox();fixture.Throttle=true;await Failure(box);saved=await Reload(box); + check("Google rate limit schedules retry and retains connected state",saved.Status=="Connected"&&saved.NextAttemptAt>=DateTime.UtcNow.AddSeconds(110)&&saved.FailureCount==1);requests=fixture.Requests;await google.Sync(saved,default); + check("Mailbox backoff skips provider requests until due",fixture.Requests==requests); + check("Owner cannot bypass provider backoff",!await MailboxManagement.Change(store,saved,saved.Version,"retry"));fixture.Throttle=false; + box=await NewBox();fixture.TokenError="invalid_client";await Failure(box);saved=await Reload(box);check("Invalid Google client is distinguished from revoked user consent",saved.Status=="Connected"&&saved.SyncErrorCode=="Configuration"&&saved.NextAttemptAt>DateTime.UtcNow.AddMinutes(59));fixture.TokenError=""; + box=await NewBox();var old=await Reload(box);await MailboxManagement.Change(store,box,box.Version,"disconnect");saved=await Reload(box); + check("Disconnect removes credentials and rotates connection identity",saved.Status=="Disconnected"&&saved.ProtectedRefreshToken==""&&!saved.CanSend&&saved.ConnectionEpoch!=""); + old.SyncError="stale worker";old.PageToken="stale-page";await store.SaveSync(old);saved=await Reload(box); + check("Stale worker cannot undo disconnect or restore checkpoint",saved.Status=="Disconnected"&&saved.SyncError!="stale worker"&&saved.PageToken!="stale-page"); + bool denied=false;try{await google.AccessToken(old,default);}catch(MailboxConflict){denied=true;}check("Disconnected snapshot cannot request access token",denied); + denied=false;try{await google.Connect(hotel.Id,"fixture-code",DateTime.UtcNow.AddMinutes(-5));}catch(MailboxConflict){denied=true;}check("OAuth started before disconnect cannot reconnect mailbox",denied); + var epoch=saved.ConnectionEpoch;await google.Connect(hotel.Id,"fixture-code",DateTime.UtcNow.AddSeconds(1),box.Email);saved=await Reload(box); + check("Fresh reconnect preserves mailbox ID and rotates approval identity",saved.Status=="Connected"&&saved.ConnectionEpoch!=epoch&&saved.Id==box.Id&&saved.PageToken==""); + denied=false;try{await google.Connect(hotel.Id,"fixture-code",DateTime.UtcNow.AddSeconds(1),"wrong@example.invalid");}catch(InvalidOperationException){denied=true;}check("Targeted reconnect rejects different Google account",denied); + fixture.Scope="https://www.googleapis.com/auth/gmail.send";denied=false;try{await google.Connect(hotel.Id,"fixture-code",DateTime.UtcNow.AddSeconds(1));}catch(InvalidOperationException){denied=true;}check("Connection requires granted Gmail read scope",denied);fixture.Scope=Fixture.FullScope; + denied=false;try{await google.Connect("another-hotel","fixture-code",DateTime.UtcNow.AddSeconds(1));}catch(MailboxConflict){denied=true;}check("Google mailbox cannot be reassigned to a different hotel",denied); + box=await NewBox();old=await Reload(box);check("Owner can restart an import pass",await MailboxManagement.Change(store,box,box.Version,"retry"));old.PageToken="old-inflight-page";await store.SaveSync(old);saved=await Reload(box);check("Old sync cannot overwrite explicitly restarted checkpoint",saved.PageToken==""&&saved.SyncErrorCode=="RestartQueued"); + box=await NewBox();fixture.OnMessage=async()=>{var current=await Reload(box);await MailboxManagement.Change(store,current,current.Version,"disconnect");};var before=(await store.List(hotel.Id)).Count;await google.Sync(box,default);fixture.OnMessage=null; + check("Disconnect during message fetch prevents subsequent import",(await store.List(hotel.Id)).Count==before); + box=await NewBox();var conversation=new Conversation{HotelId=hotel.Id,MailboxId=box.Id,ProviderMessageId="pending",ProviderThreadId="ab123",RfcMessageId="",Subject="Parking",Delivery=new(){Recipient="guest@example.invalid",Body="Approved answer",MailboxEpoch="previous-connection"}};await store.Insert(conversation);await new ReplyDelivery(store,google).Process(conversation,default); + check("Reconnection does not silently send earlier queued approvals",(await store.Get(hotel.Id,conversation.Id))!.Delivery!.State=="Rejected"&&fixture.Sends==0); + conversation=new Conversation{HotelId=hotel.Id,MailboxId=box.Id,ProviderMessageId="during-token",ProviderThreadId="ab123",RfcMessageId="",Subject="Parking",Delivery=new(){Recipient="guest@example.invalid",Body="Approved answer",MailboxEpoch=box.ConnectionEpoch}};await store.Insert(conversation);fixture.OnToken=async()=>{var current=await Reload(box);await MailboxManagement.Change(store,current,current.Version,"disconnect");};await new ReplyDelivery(store,google).Process(conversation,default);fixture.OnToken=null; + check("Disconnect during token refresh blocks Gmail send",(await store.Get(hotel.Id,conversation.Id))!.Delivery!.State=="Rejected"&&fixture.Sends==0); + var view=JsonSerializer.Serialize(MailboxManagement.View(box));check("Mailbox health view omits credentials and page tokens",!view.Contains("ProtectedRefreshToken")&&!view.Contains("PageToken")&&!view.Contains("ConnectionEpoch")); + } + sealed class Fixture:HttpMessageHandler + { + public const string FullScope="https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send"; + public string Email="",TokenError="",Scope=FullScope;public bool NextPage,BadPage,Throttle;public int Requests,Sends;public Func? OnMessage,OnToken; + static HttpResponseMessage Json(object value,HttpStatusCode status=HttpStatusCode.OK)=>new(status){Content=new StringContent(JsonSerializer.Serialize(value),Encoding.UTF8,"application/json")}; + protected override async Task SendAsync(HttpRequestMessage request,CancellationToken ct) + { + Requests++;var path=request.RequestUri!.AbsolutePath; + if(path=="/token") {if(OnToken!=null)await OnToken();return TokenError!=""?Json(new{error=TokenError,error_description="fixture-secret-never-display"},HttpStatusCode.BadRequest):Json(new{access_token="fixture-access",refresh_token="fixture-new-refresh",scope=Scope});} + if(path.EndsWith("/profile"))return Json(new{emailAddress=Email}); + if(path.EndsWith("/messages")) + { + if(Throttle){var result=Json(new{error=new{code=429}},HttpStatusCode.TooManyRequests);result.Headers.RetryAfter=new(TimeSpan.FromSeconds(120));return result;} + if(BadPage&&request.RequestUri.Query.Contains("pageToken="))return Json(new{error=new{code=400}},HttpStatusCode.BadRequest); + return NextPage?Json(new{messages=new[]{new{id="deleted"},new{id="fixture-message"}},nextPageToken="fixture-next"}):Json(new{messages=new[]{new{id="deleted"},new{id="fixture-message"}}}); + } + if(path.EndsWith("/messages/deleted"))return Json(new{error=new{code=404}},HttpStatusCode.NotFound); + if(path.EndsWith("/messages/fixture-message")) + { + if(OnMessage!=null)await OnMessage();return Json(new{id="fixture-message",threadId="ab123",internalDate=DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(),payload=new{mimeType="text/plain",headers=new[]{new{name="From",value="guest@example.invalid"},new{name="To",value=Email},new{name="Subject",value="Parking"},new{name="Message-ID",value=""}},body=new{data=Convert.ToBase64String(Encoding.UTF8.GetBytes("Is parking available?"))}}}); + } + if(path.EndsWith("/messages/send")){Sends++;return Json(new{id="sent"});} + throw new InvalidOperationException("Unexpected fixture request."); + } + } +} diff --git a/tests/GuestOps.Tests/Program.cs b/tests/GuestOps.Tests/Program.cs index 4439bc3..65f47df 100644 --- a/tests/GuestOps.Tests/Program.cs +++ b/tests/GuestOps.Tests/Program.cs @@ -15,6 +15,7 @@ IStore store = uri == null ? new PreviewStore() : new MongoStore(new Configurati await store.Initialize(); try { + await MailboxTests.Run(Check, store); await TeamTests.Run(Check, store); await ReplyTests.Run(Check, store); await PmsTests.Run(Check, store); @@ -54,6 +55,10 @@ try oldMailbox!.SyncError = "stale worker failure"; await store.SaveSync(oldMailbox); var newMailbox = await store.Get(a.Id, mailbox.Id); Check("Stale worker cannot overwrite reconnected credentials", newMailbox?.ProtectedRefreshToken == "new-protected-token" && newMailbox.SyncError == ""); + await new MongoClient(uri).GetDatabase(dbName).GetCollection("mailbox").UpdateOneAsync(x=>x.Id==mailbox.Id,Builders.Update.Unset(x=>x.Version)); + var legacy=(await store.Get(a.Id,mailbox.Id))!;legacy.SyncError="Legacy sync health";await store.SaveSync(legacy); + Check("Pre-migration mailboxes accept guarded health updates",(await store.Get(a.Id,mailbox.Id))?.SyncError=="Legacy sync health"); + Check("Pre-migration mailbox can be disconnected with version zero",await MailboxManagement.Change(store,legacy,0,"disconnect")); } var parsed = AiExtractionPrompt.BuildRowsFromJson("{\"action\":\"CreateBooking\",\"first_name\":\"Guest {test}\"}", new ParsedBooking()); Check("Migrated parser preserves braces in JSON strings", parsed.Count > 0 && parsed[0].GuestFirstName == "Guest {test}"); @@ -79,6 +84,10 @@ try Check("Draft save succeeds", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="Hello guest",version=0})).IsSuccessStatusCode); Check("Stale API draft save returns conflict", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="old",version=0})).StatusCode==HttpStatusCode.Conflict); Check("Preview cannot connect real Gmail", !(await one.PostAsJsonAsync("/api/integrations/google/connect",new {})).IsSuccessStatusCode); + var boxes=await Read(one,"/api/mailboxes");var boxId=boxes.GetProperty("items")[0].GetProperty("id").GetString(); + Check("Mailbox health includes recovery state without secrets",boxes.GetProperty("items")[0].GetProperty("syncErrorCode").GetString()=="ReconnectRequired"&&!boxes.GetRawText().Contains("protectedRefreshToken")&&!boxes.GetRawText().Contains("pageToken")); + foreach(var action in new[]{"disconnect","reconnect","retry"})Check("Other hotel cannot "+action+" a mailbox",(await two.PostAsJsonAsync($"/api/mailboxes/{boxId}/{action}",new{version=0})).StatusCode==HttpStatusCode.NotFound); + Check("Preview recovery cannot change real Google state",(await one.PostAsJsonAsync($"/api/mailboxes/{boxId}/reconnect",new{version=0})).StatusCode==HttpStatusCode.BadRequest); Check("Preview cannot enable paid AI", (await one.PutAsJsonAsync("/api/reply-controls",new {version=0,aiDraftsEnabled=true,staffSendingEnabled=false})).StatusCode==HttpStatusCode.BadRequest); Check("Cross-hotel reply approval is blocked", (await two.PostAsJsonAsync($"/api/conversations/{id}/send",new {version=1,recipient="guest@example.invalid"})).StatusCode==HttpStatusCode.NotFound); Check("Cross-hotel AI generation is blocked", (await two.PostAsJsonAsync($"/api/conversations/{id}/generate",new {version=1})).StatusCode==HttpStatusCode.NotFound); @@ -124,3 +133,4 @@ finally + diff --git a/tests/GuestOps.Tests/TeamTests.cs b/tests/GuestOps.Tests/TeamTests.cs index 711b465..7dbc26b 100644 --- a/tests/GuestOps.Tests/TeamTests.cs +++ b/tests/GuestOps.Tests/TeamTests.cs @@ -63,6 +63,7 @@ public static class TeamTests await Csrf(staff);check("Invited colleague can sign in",(await staff.PostAsJsonAsync("/api/auth/login",new{email,password})).IsSuccessStatusCode);await Csrf(staff); check("Staff cannot list or invite team members",(await staff.GetAsync("/api/team")).StatusCode==HttpStatusCode.Forbidden&&(await staff.PostAsJsonAsync("/api/team/invite",new{name="No",email="no@example.invalid"})).StatusCode==HttpStatusCode.Forbidden); check("Staff cannot change hotel settings",(await staff.PutAsJsonAsync("/api/hotel",new{name="No",signature="",timezone="UTC",version=0})).StatusCode==HttpStatusCode.Forbidden); + foreach(var action in new[]{"disconnect","reconnect","retry"})check("Staff cannot "+action+" mailboxes",(await staff.PostAsJsonAsync($"/api/mailboxes/unknown/{action}",new{version=0})).StatusCode==HttpStatusCode.Forbidden); check("Foreign hotel cannot reset staff",(await other.PostAsJsonAsync($"/api/team/{id}/reset",new{version=2})).StatusCode==HttpStatusCode.NotFound); var list=await Read(await owner.GetAsync("/api/team"));var member=list.EnumerateArray().Single(x=>x.GetProperty("id").GetString()==id);var version=member.GetProperty("version").GetInt64(); check("HTTP team listing excludes secrets",!list.GetRawText().Contains("passwordHash")&&!list.GetRawText().Contains("securityStamp")&&!list.GetRawText().Contains(token)); diff --git a/web/src/MailboxPanel.tsx b/web/src/MailboxPanel.tsx new file mode 100644 index 0000000..20714a9 --- /dev/null +++ b/web/src/MailboxPanel.tsx @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; +import { Mail, RefreshCw, ShieldCheck } from 'lucide-react'; +import { api, type Mailboxes } from './api'; +type Run=(action:()=>Promise)=>Promise; +const when=(value:string|null)=>value?new Date(value).toLocaleString():'Not yet'; +export function MailboxPanel({data,owner,preview,busy,run,onChange}:{data:Mailboxes;owner:boolean;preview:boolean;busy:boolean;run:Run;onChange:(value:Mailboxes)=>void}){ + const [pollError,setPollError]=useState(''); + async function refresh(){onChange(await api('/mailboxes'));setPollError('');} + useEffect(()=>{let active=true;const timer=setInterval(()=>{if(document.hidden)return;api('/mailboxes').then(value=>{if(active){onChange(value);setPollError('');}}).catch(()=>{if(active)setPollError('Status could not be refreshed. Use Refresh status to check again.');});},30000);return()=>{active=false;clearInterval(timer);};},[onChange]); + async function act(id:string,version:number,action:string,email:string){ + if(action==='disconnect'&&!window.confirm(`Disconnect ${email} from GuestOps? New import and reply work will stop and saved Google credentials will be removed. An in-flight request may finish. Imported emails and drafts stay in this workspace. Google account access must be removed separately.`))return; + await run(async()=>{const result=await api<{url?:string}>(`/mailboxes/${id}/${action}`,'POST',{version});if(result?.url)location.assign(result.url);else await refresh();}); + } + return

Google mailbox

Keep guest messages flowing into your shared inbox.

{pollError&&

{pollError}

} + {data.items.map(m=>{const connected=m.status==='Connected';const waiting=!!m.nextAttemptAt&&new Date(m.nextAttemptAt)>new Date();const label=m.status==='NeedsReconnect'?'Reconnect needed':m.status==='Disconnected'?'Disconnected':m.syncError?'Waiting for retry':m.catchingUp?'Catching up':'Connected';return
G{m.email}{label}
Last successful import{when(m.lastSyncAt)}
Last attempt{when(m.lastAttemptAt)}
Next check{waiting?when(m.nextAttemptAt):connected?'Next worker cycle':'After reconnection'}
{m.syncError&&

{m.syncError}

}

{connected?(m.canSend?'Google sending permission granted. Hotel and reply approval controls still apply.':'Read-only connection. Reconnect after administrator enablement to grant sending permission.'):'GuestOps cannot start new mailbox work while disconnected or awaiting reconnection.'}

{connected&&}{m.status!=='Disconnected'&&}
{m.status==='Disconnected'&&

To also remove Google's authorization, open your Google account connections and remove GuestOps access. This may affect other sessions using the same Google app.

}
;})} + {!data.items.length&&

Connect your hotel inbox

Authorize the Google account your team uses for guest messages. The first import covers seven days.

} + + {preview?

Sample workspace only. Real mailbox connection and recovery controls are unavailable here.

:!data.configured&&

Your administrator must configure Google before connection is available.

} +
Disconnecting keeps imported messages and drafts. Requests already in progress may finish. Reconnection keeps the same mailbox history; queued replies from an earlier connection require review.
+
; +} diff --git a/web/src/ReplyActions.tsx b/web/src/ReplyActions.tsx index c1bac51..e638cc8 100644 --- a/web/src/ReplyActions.tsx +++ b/web/src/ReplyActions.tsx @@ -19,7 +19,7 @@ export function ReplyActions({message,hotel,mailboxes,knowledge,dirty,busy,run,o onUpdate(await api(`/conversations/${message.id}/send`,'POST',{version:message.version,recipient:message.replyAddress})); }); const act=(action:string)=>run(async()=>onUpdate(await api(`/conversations/${message.id}/delivery/${action}`,'POST',{version:message.version}))); - const canSend=hotel.staffSendingEnabled&&mailboxes.sendingConfigured&&mailboxes.items.some(m=>m.id===message.mailboxId&&m.canSend); + const canSend=hotel.staffSendingEnabled&&mailboxes.sendingConfigured&&mailboxes.items.some(m=>m.id===message.mailboxId&&m.canSend&&m.status==='Connected'); return
{message.autoReplyDetail&&

FAQ check: {message.autoReplyDetail}

} {delivery?.automatic&&

This reply uses an owner-approved FAQ rule.

} diff --git a/web/src/api.ts b/web/src/api.ts index c359606..36fa9d1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -4,7 +4,7 @@ export type Hotel = { id: string; name: string; timezone: string; signature: str export type Conversation = { id: string; from: string; subject: string; body: string; receivedAt: string; status: string; draft: string; category: string; note: string; providerThreadId: string; version: number; mailboxId: string; autoReplyDetail: string; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { automatic: boolean; state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null }; export type Knowledge = { id: string; title: string; category: string; answer: string; keywords: string; approved: boolean; version: number }; export type Activity = { id: string; at: string; userName: string; action: string }; -export type Mailboxes = { configured: boolean; aiConfigured?: boolean; sendingConfigured?: boolean; items: { id: string; email: string; status: string; lastSyncAt: string | null; syncError: string; canSend: boolean }[] }; +export type Mailboxes = { configured: boolean; aiConfigured?: boolean; sendingConfigured?: boolean; items: { id: string; email: string; status: string; version: number; lastSyncAt: string | null; lastAttemptAt: string | null; nextAttemptAt: string | null; failureCount: number; syncErrorCode: string; catchingUp: boolean; syncError: string; canSend: boolean }[] }; let csrf = ''; export async function api(path: string, method = 'GET', body?: unknown): Promise { const response = await fetch('/api' + path, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }, body: body === undefined ? undefined : JSON.stringify(body) }); diff --git a/web/src/main.tsx b/web/src/main.tsx index b2c109f..e179ffc 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react'; import { api, session, type Session, type Hotel, type Conversation, type Knowledge, type Activity, type Mailboxes } from './api'; -import './style.css'; +import './style.css'; +import { MailboxPanel } from './MailboxPanel'; import { TeamPage, OnboardingPage, AccountPage } from './TeamPage'; import { AutomationPage } from './AutomationPage'; import { PaymentsPage } from './PaymentsPage'; @@ -47,7 +48,7 @@ function App() {
Workspace{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':'Settings'}
{auth.preview&&Preview · sample data}{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}
{errorBox}{notice&&
{notice}
} - {!loaded?

Loading your hotel…

:page==='/team'?:page==='/setup'?:page==='/inbox'?setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?:page==='/payments'?:page==='/reservations'?:page==='/knowledge'?setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?
{activity.length?activity.map(a=>
{a.action}

{a.userName}

):}
:{setHotel(h);setNotice('Hotel settings saved.');}}/>} + {!loaded?

Loading your hotel…

:page==='/team'?:page==='/setup'?:page==='/inbox'?setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?:page==='/payments'?:page==='/reservations'?:page==='/knowledge'?setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?
{activity.length?activity.map(a=>
{a.action}

{a.userName}

):}
:{setHotel(h);setNotice('Hotel settings saved.');}}/>}
; } @@ -79,10 +80,10 @@ function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge async function save(e:React.FormEvent) {e.preventDefault();if(!edit)return;await run(async()=>{const result=await api('/knowledge'+(edit.id?'/'+edit.id:''),edit.id?'PUT':'POST',edit);onUpdate(result);setEdit(null);notify('Hotel knowledge saved.');});} return
{canEdit&&}
Your hotel's source of truth

Approve answers before your team uses them in a reply. Keep changing details up to date.

{items.filter(x=>x.approved).length} approved
{items.filter(k=>(k.title+' '+k.answer).toLowerCase().includes(search.toLowerCase())).map(k=>
{k.category}{k.approved?'Approved':'Not approved'}

{k.title}

{k.answer}

{canEdit&&}
)}
{!items.length&&}{edit&&

{edit.id?'Edit answer':'Add an answer'}