Add Google mailbox lifecycle controls and guarded sync recovery
This commit is contained in:
parent
7fcf51c900
commit
bc7cbc1492
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
37
docs/mailboxes.md
Normal file
37
docs/mailboxes.md
Normal file
@ -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.
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -68,7 +68,7 @@ public sealed class AutoReplyWork(IStore store,IConfiguration config)
|
||||
else
|
||||
{
|
||||
var rule=(await store.Get<AutoReplyRule>(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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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", ""),
|
||||
|
||||
@ -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<JsonElement> 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<Mailbox>(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<string> 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<bool> AutoReplyThreadUnchanged(Conversation message,string token,CancellationToken ct)
|
||||
{
|
||||
|
||||
47
src/GuestOps.Api/MailboxManagement.cs
Normal file
47
src/GuestOps.Api/MailboxManagement.cs
Normal file
@ -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<bool> Current(IStore store,Mailbox box)
|
||||
{
|
||||
var current=await store.Get<Mailbox>(box.HotelId,box.Id);return current?.Status=="Connected"&¤t.Version==box.Version&¤t.ProtectedRefreshToken==box.ProtectedRefreshToken;
|
||||
}
|
||||
public static async Task<bool> 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<Mailbox>(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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;}
|
||||
|
||||
@ -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<Hotel>(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<Activity>(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<Mailbox>(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<Mailbox>(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<Conversation>(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<Hotel>(item.HotelId, item.HotelId); var mailbox = await store.Get<Mailbox>(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<Conversation>(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<Mailbox>(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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -48,11 +48,12 @@ public sealed class ReplyDelivery(IStore store, GoogleMailbox google, IConfigura
|
||||
{
|
||||
var hotel = await store.Get<Hotel>(message.HotelId, message.HotelId);
|
||||
var mailbox = await store.Get<Mailbox>(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; }
|
||||
|
||||
@ -19,6 +19,7 @@ public interface IStore
|
||||
Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user);
|
||||
Task<List<Mailbox>> Mailboxes();
|
||||
Task SaveMailbox(Mailbox mailbox);
|
||||
Task<bool> TryInsertMailbox(Mailbox mailbox);
|
||||
Task SaveSync(Mailbox mailbox);
|
||||
Task<bool> 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<T>.Filter.Eq("Version",version);
|
||||
if(typeof(T)==typeof(StaffUser)&&version==0)versionFilter|=Builders<T>.Filter.Exists("Version",false);
|
||||
if((typeof(T)==typeof(StaffUser)||typeof(T)==typeof(Mailbox))&&version==0)versionFilter|=Builders<T>.Filter.Exists("Version",false);
|
||||
var result = await Collection<T>().ReplaceOneAsync(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id) & versionFilter, document);
|
||||
return result.ModifiedCount == 1;
|
||||
}
|
||||
@ -97,9 +98,11 @@ public sealed class MongoStore : IStore
|
||||
public async Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user) => await Collection<OAuthRequest>().FindOneAndDeleteAsync(x => x.Id == id && x.HotelId == hotel && x.UserId == user && x.ExpiresAt > DateTime.UtcNow);
|
||||
public Task<List<Mailbox>> Mailboxes() => Collection<Mailbox>().Find(x => x.Status == "Connected").ToListAsync();
|
||||
public async Task SaveMailbox(Mailbox mailbox) => await Collection<Mailbox>().ReplaceOneAsync(x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id, mailbox, new ReplaceOptions { IsUpsert = true });
|
||||
public async Task<bool> 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<Mailbox>().UpdateOneAsync(
|
||||
x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id && x.ProtectedRefreshToken == mailbox.ProtectedRefreshToken,
|
||||
Builders<Mailbox>.Filter.Eq(x=>x.HotelId,mailbox.HotelId)&Builders<Mailbox>.Filter.Eq(x=>x.Id,mailbox.Id)&Builders<Mailbox>.Filter.Eq(x=>x.ProtectedRefreshToken,mailbox.ProtectedRefreshToken)&Builders<Mailbox>.Filter.Eq(x=>x.Status,"Connected")&(mailbox.Version==0?(Builders<Mailbox>.Filter.Eq(x=>x.Version,0)|Builders<Mailbox>.Filter.Exists("Version",false)):Builders<Mailbox>.Filter.Eq(x=>x.Version,mailbox.Version)),
|
||||
Builders<Mailbox>.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<bool> TryLease(string id, string owner)
|
||||
{
|
||||
@ -190,7 +193,8 @@ public sealed class PreviewStore : IStore
|
||||
public Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user) { lock(gate) { var x = rows.TryGetValue(Key<OAuthRequest>(id), out var raw) ? Clone<OAuthRequest>(raw) : null; if(x?.HotelId != hotel || x.UserId != user || x.ExpiresAt <= DateTime.UtcNow) return Task.FromResult<OAuthRequest?>(null); rows.TryRemove(Key<OAuthRequest>(id),out _); return Task.FromResult<OAuthRequest?>(x); } }
|
||||
public Task<List<Mailbox>> Mailboxes() => Task.FromResult(new List<Mailbox>());
|
||||
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<bool> TryInsertMailbox(Mailbox mailbox){lock(gate){if(rows.Where(x=>x.Key.StartsWith("Mailbox:")).Select(x=>Clone<Mailbox>(x.Value)).Any(x=>x.Email==mailbox.Email))return Task.FromResult(false);return Task.FromResult(rows.TryAdd(Key<Mailbox>(mailbox.Id),Json(mailbox)));}}
|
||||
public Task SaveSync(Mailbox mailbox){lock(gate){if(rows.TryGetValue(Key<Mailbox>(mailbox.Id),out var raw)){var old=Clone<Mailbox>(raw);if(old.HotelId==mailbox.HotelId&&old.Version==mailbox.Version&&old.Status=="Connected"&&old.ProtectedRefreshToken==mailbox.ProtectedRefreshToken)rows[Key<Mailbox>(mailbox.Id)]=Json(mailbox);}return Task.CompletedTask;}}
|
||||
public Task<bool> 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<Conversation>(message.HotelId)).Any(x => x.MailboxId == message.MailboxId && x.ProviderMessageId == message.ProviderMessageId)) await Insert(message); }
|
||||
|
||||
@ -31,7 +31,7 @@ sealed class MailboxWorker(IStore store, IServiceScopeFactory factory, ILogger<M
|
||||
{
|
||||
foreach (var mailbox in await store.Mailboxes())
|
||||
{
|
||||
if (!await store.TryLease(mailbox.Id, owner)) continue;
|
||||
if (mailbox.NextAttemptAt > 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<GoogleMailbox>().Sync(mailbox, deadline.Token); }
|
||||
@ -39,8 +39,8 @@ sealed class MailboxWorker(IStore store, IServiceScopeFactory factory, ILogger<M
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogWarning("Mailbox synchronization paused for {Id} ({Type})", mailbox.Id, ex.GetType().Name);
|
||||
mailbox.SyncError = "Synchronization failed. Retry later or reconnect your Google mailbox.";
|
||||
await store.SaveSync(mailbox);
|
||||
using var failureScope = factory.CreateScope();
|
||||
await failureScope.ServiceProvider.GetRequiredService<GoogleMailbox>().RecordFailure(mailbox,ex);
|
||||
}
|
||||
finally { await store.ReleaseLease(mailbox.Id, owner); }
|
||||
}
|
||||
@ -95,3 +95,4 @@ sealed class AutoReplyWorker(IStore store,IServiceScopeFactory factory,ILogger<A
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
78
tests/GuestOps.Tests/MailboxTests.cs
Normal file
78
tests/GuestOps.Tests/MailboxTests.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using GuestOps.Web;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
public static class MailboxTests
|
||||
{
|
||||
public static async Task Run(Action<string,bool> check,IStore store)
|
||||
{
|
||||
var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{"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<Mailbox> 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<Mailbox> Reload(Mailbox box)=>(await store.Get<Mailbox>(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<Conversation>(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<Conversation>(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<Conversation>(hotel.Id)).Count;await google.Sync(box,default);fixture.OnMessage=null;
|
||||
check("Disconnect during message fetch prevents subsequent import",(await store.List<Conversation>(hotel.Id)).Count==before);
|
||||
box=await NewBox();var conversation=new Conversation{HotelId=hotel.Id,MailboxId=box.Id,ProviderMessageId="pending",ProviderThreadId="ab123",RfcMessageId="<guest@example.invalid>",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<Conversation>(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="<guest@example.invalid>",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<Conversation>(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<Task>? 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<HttpResponseMessage> 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="<fixture@example.invalid>"}},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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Mailbox>(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>("mailbox").UpdateOneAsync(x=>x.Id==mailbox.Id,Builders<Mailbox>.Update.Unset(x=>x.Version));
|
||||
var legacy=(await store.Get<Mailbox>(a.Id,mailbox.Id))!;legacy.SyncError="Legacy sync health";await store.SaveSync(legacy);
|
||||
Check("Pre-migration mailboxes accept guarded health updates",(await store.Get<Mailbox>(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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
21
web/src/MailboxPanel.tsx
Normal file
21
web/src/MailboxPanel.tsx
Normal file
@ -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<void>)=>Promise<void>;
|
||||
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>('/mailboxes'));setPollError('');}
|
||||
useEffect(()=>{let active=true;const timer=setInterval(()=>{if(document.hidden)return;api<Mailboxes>('/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 <section className="settings-card mailbox-panel"><div className="section-heading"><Mail size={21}/><div><h2>Google mailbox</h2><p>Keep guest messages flowing into your shared inbox.</p></div><button className="button text compact" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={15}/>Refresh status</button></div>{pollError&&<p className="alert" role="status">{pollError}</p>}
|
||||
{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 <article className="mailbox-detail" key={m.id}><div className="mailbox-title"><span className="google-mark">G</span><strong>{m.email}</strong><span className={'status '+(connected&&!m.syncError?'Completed':'NeedsAttention')}>{label}</span></div><div className="mailbox-health"><div><span>Last successful import</span><strong>{when(m.lastSyncAt)}</strong></div><div><span>Last attempt</span><strong>{when(m.lastAttemptAt)}</strong></div><div><span>Next check</span><strong>{waiting?when(m.nextAttemptAt):connected?'Next worker cycle':'After reconnection'}</strong></div></div>{m.syncError&&<p className="mailbox-explanation" role="status">{m.syncError}</p>}<p className="small muted">{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.'}</p><div className="mailbox-buttons"><button className="button secondary compact" disabled={busy||preview||!owner||!data.configured} onClick={()=>act(m.id,m.version,'reconnect',m.email)}>Reconnect Google</button>{connected&&<button className="button secondary compact" disabled={busy||preview||!owner||waiting} onClick={()=>act(m.id,m.version,'retry',m.email)}>Restart import pass</button>}{m.status!=='Disconnected'&&<button className="button text compact" disabled={busy||preview||!owner} onClick={()=>act(m.id,m.version,'disconnect',m.email)}>Disconnect from GuestOps</button>}</div>{m.status==='Disconnected'&&<p className="small muted">To also remove Google's authorization, open <a href="https://myaccount.google.com/permissions" target="_blank" rel="noopener noreferrer">your Google account connections</a> and remove GuestOps access. This may affect other sessions using the same Google app.</p>}</article>;})}
|
||||
{!data.items.length&&<div className="mailbox-empty"><Mail size={28}/><h3>Connect your hotel inbox</h3><p>Authorize the Google account your team uses for guest messages. The first import covers seven days.</p></div>}
|
||||
<button className="button secondary" disabled={busy||preview||!owner||!data.configured} onClick={()=>run(async()=>{const result=await api<{url:string}>('/integrations/google/connect','POST');location.assign(result.url);})}><span className="google-mark">G</span>{data.items.length?'Connect another mailbox':'Connect Google mailbox'}</button>
|
||||
{preview?<p className="small muted">Sample workspace only. Real mailbox connection and recovery controls are unavailable here.</p>:!data.configured&&<p className="small muted">Your administrator must configure Google before connection is available.</p>}
|
||||
<div className="settings-note"><ShieldCheck size={17}/><span>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.</span></div>
|
||||
</section>;
|
||||
}
|
||||
@ -19,7 +19,7 @@ export function ReplyActions({message,hotel,mailboxes,knowledge,dirty,busy,run,o
|
||||
onUpdate(await api<Conversation>(`/conversations/${message.id}/send`,'POST',{version:message.version,recipient:message.replyAddress}));
|
||||
});
|
||||
const act=(action:string)=>run(async()=>onUpdate(await api<Conversation>(`/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 <section className="reply-actions" aria-label="Reply review and delivery">
|
||||
{message.autoReplyDetail&&<p className="staff-note">FAQ check: {message.autoReplyDetail}</p>}
|
||||
{delivery?.automatic&&<p className="small muted">This reply uses an owner-approved FAQ rule.</p>}
|
||||
|
||||
@ -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<T>(path: string, method = 'GET', body?: unknown): Promise<T> {
|
||||
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) });
|
||||
|
||||
@ -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() {
|
||||
<main className="main">
|
||||
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{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'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
|
||||
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@ -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>('/knowledge'+(edit.id?'/'+edit.id:''),edit.id?'PUT':'POST',edit);onUpdate(result);setEdit(null);notify('Hotel knowledge saved.');});}
|
||||
return <div className="page"><div className="heading-row"><PageHeading eyebrow="Answers your team can trust" title="Hotel knowledge" text="Keep your policies and helpful answers in one place."/>{canEdit&&<button className="button primary" onClick={()=>setEdit({id:'',title:'',answer:'',keywords:'',category:'General',approved:false,version:0})}><Plus size={17}/>Add an answer</button>}</div><div className="knowledge-summary"><BookOpen size={23}/><div><strong>Your hotel's source of truth</strong><p>Approve answers before your team uses them in a reply. Keep changing details up to date.</p></div><span>{items.filter(x=>x.approved).length} approved</span></div><label className="search knowledge-search"><Search size={17}/><input placeholder="Find an answer…" aria-label="Search hotel knowledge" value={search} onChange={e=>setSearch(e.target.value)}/></label><div className="knowledge-grid">{items.filter(k=>(k.title+' '+k.answer).toLowerCase().includes(search.toLowerCase())).map(k=><article className="knowledge-card" key={k.id}><div><span className="category-label">{k.category}</span><span className={'status '+(k.approved?'Completed':'NeedsAttention')}>{k.approved?'Approved':'Not approved'}</span></div><h2>{k.title}</h2><p>{k.answer}</p>{canEdit&&<button className="button text" onClick={()=>setEdit({...k})}>Edit answer<ArrowUpRight size={15}/></button>}</article>)}</div>{!items.length&&<Empty title="What should guests know?" text="Start with check-in times, parking and breakfast information."/>}{edit&&<div className="modal-backdrop"><section className="modal" role="dialog" aria-modal="true" aria-labelledby="knowledge-title"><div className="modal-heading"><h2 id="knowledge-title">{edit.id?'Edit answer':'Add an answer'}</h2><button className="icon-button" onClick={()=>setEdit(null)} aria-label="Close editor"><X size={20}/></button></div><form onSubmit={save}><label>Title<input autoFocus value={edit.title} maxLength={120} minLength={2} required onChange={e=>setEdit({...edit,title:e.target.value})}/></label><label>Category<input value={edit.category} maxLength={50} required onChange={e=>setEdit({...edit,category:e.target.value})}/></label><label>Answer<textarea value={edit.answer} rows={6} maxLength={5000} minLength={2} required onChange={e=>setEdit({...edit,answer:e.target.value})}/></label><label>Helpful keywords<input value={edit.keywords} maxLength={300} placeholder="parking, car, arrival" onChange={e=>setEdit({...edit,keywords:e.target.value})}/></label><label className="checkbox-label"><input type="checkbox" checked={edit.approved} onChange={e=>setEdit({...edit,approved:e.target.checked})}/>Approved for staff to use</label><div className="form-actions"><button type="button" className="button secondary" onClick={()=>setEdit(null)}>Cancel</button><button className="button primary" disabled={busy}>Save answer</button></div></form></section></div>}</div>;
|
||||
}
|
||||
function SettingsPage({hotel,mailboxes,preview,owner,busy,run,onSave}:{hotel:Hotel;mailboxes:Mailboxes;preview:boolean;owner:boolean;busy:boolean;run:Run;onSave:(h:Hotel)=>void}){
|
||||
function SettingsPage({hotel,mailboxes,preview,owner,busy,run,onSave,onMailboxes}:{hotel:Hotel;mailboxes:Mailboxes;preview:boolean;owner:boolean;busy:boolean;run:Run;onSave:(h:Hotel)=>void;onMailboxes:(value:Mailboxes)=>void}){
|
||||
const [form,setForm]=useState(hotel);useEffect(()=>setForm(hotel),[hotel]);
|
||||
const result=new URLSearchParams(location.search).get('google');
|
||||
return <div className="page settings-page"><PageHeading eyebrow="Make yourself at home" title="Hotel settings" text="The details that make this workspace yours."/>{result&&<div className={result==='connected'?'success-note':'alert'} role="status">{result==='connected'?'Google mailbox connected. Recent messages will appear after synchronization.':result==='cancelled'?'Google connection was cancelled.':'Google connection could not be completed. Try again or contact your administrator.'}</div>}<section className="settings-card"><div className="section-heading"><Building2 size={20}/><div><h2>Your hotel</h2><p>Shared with everyone in this workspace.</p></div></div><form onSubmit={e=>{e.preventDefault();run(async()=>onSave(await api<Hotel>('/hotel','PUT',form)));}}><fieldset disabled={!owner||busy}><div className="form-grid"><label>Hotel name<input value={form.name} minLength={2} maxLength={120} required onChange={e=>setForm({...form,name:e.target.value})}/></label><label>Timezone<select value={form.timezone} onChange={e=>setForm({...form,timezone:e.target.value})}>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label></div><label>Email signature<textarea rows={4} maxLength={2000} value={form.signature} onChange={e=>setForm({...form,signature:e.target.value})}/></label><div className="form-actions"><button className="button primary" disabled={busy}>Save hotel settings</button></div></fieldset></form></section><section className="settings-card"><div className="section-heading"><Mail size={21}/><div><h2>Connected mailbox</h2><p>Bring recent guest messages into your shared inbox.</p></div></div>{mailboxes.items.map(m=><div className="mailbox" key={m.id}><span className="google-mark">G</span><div><strong>{m.email}</strong><small>{m.syncError||(m.lastSyncAt?'Last synced '+date(m.lastSyncAt):'Waiting for first synchronization')}</small></div><span className="status Completed">{m.status}</span></div>)}{!mailboxes.items.length&&<p className="muted">No mailbox connected yet.</p>}<button className="button secondary" disabled={busy||!mailboxes.configured||!owner} onClick={()=>run(async()=>{const r=await api<{url:string}>('/integrations/google/connect','POST');location.assign(r.url);})}><span className="google-mark">G</span>{mailboxes.items.length?'Connect or reconnect Google':'Connect Google mailbox'}<ArrowUpRight size={16}/></button>{!mailboxes.configured&&<p className="small muted">{preview?'Real mailbox connections are unavailable in this sample workspace.':'Your administrator needs to configure Google connection before this is available.'}</p>}<div className="settings-note"><ShieldCheck size={17}/><span>Sending requires Google permission, the hotel sending control and staff approval of each reply. GuestOps does not delete your Google emails.</span></div></section><ReplyControls hotel={hotel} mailboxes={mailboxes} owner={owner} busy={busy} run={run} onSave={onSave}/></div>;
|
||||
return <div className="page settings-page"><PageHeading eyebrow="Make yourself at home" title="Hotel settings" text="The details that make this workspace yours."/>{result&&<div className={result==='connected'?'success-note':'alert'} role="status">{result==='connected'?'Google mailbox connected. Recent messages will appear after synchronization.':result==='cancelled'?'Google connection was cancelled.':'Google connection could not be completed. Try again or contact your administrator.'}</div>}<section className="settings-card"><div className="section-heading"><Building2 size={20}/><div><h2>Your hotel</h2><p>Shared with everyone in this workspace.</p></div></div><form onSubmit={e=>{e.preventDefault();run(async()=>onSave(await api<Hotel>('/hotel','PUT',form)));}}><fieldset disabled={!owner||busy}><div className="form-grid"><label>Hotel name<input value={form.name} minLength={2} maxLength={120} required onChange={e=>setForm({...form,name:e.target.value})}/></label><label>Timezone<select value={form.timezone} onChange={e=>setForm({...form,timezone:e.target.value})}>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label></div><label>Email signature<textarea rows={4} maxLength={2000} value={form.signature} onChange={e=>setForm({...form,signature:e.target.value})}/></label><div className="form-actions"><button className="button primary" disabled={busy}>Save hotel settings</button></div></fieldset></form></section><MailboxPanel data={mailboxes} owner={owner} preview={preview} busy={busy} run={run} onChange={onMailboxes}/><ReplyControls hotel={hotel} mailboxes={mailboxes} owner={owner} busy={busy} run={run} onSave={onSave}/></div>;
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<App/>);
|
||||
|
||||
|
||||
@ -17,3 +17,5 @@
|
||||
.account-layout h1{margin:28px 0 14px}.account-layout p{margin:12px 0;line-height:1.6}.account-layout form{margin-top:24px}.account-link p{margin:12px 0;line-height:1.6}.team-list{margin-top:12px}
|
||||
@media(min-width:1001px) and (max-height:950px){.sidebar .mode-card{display:none}.sidebar-bottom{padding-top:10px}.profile{margin-top:10px;padding-top:12px}.sidebar{padding-top:20px}.hotel-switch{margin-top:20px;margin-bottom:20px}.nav-item{padding-top:9px;padding-bottom:9px;margin-bottom:4px}}
|
||||
@media(min-width:1001px) and (max-height:800px){.sidebar .nav-label{display:none}}
|
||||
.mailbox-panel .section-heading{flex-wrap:wrap}.mailbox-panel .section-heading>button{margin-left:auto}.mailbox-detail{border:1px solid var(--border);border-radius:10px;padding:20px;margin:20px 0}.mailbox-title{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.mailbox-title strong{overflow-wrap:anywhere}.mailbox-title .status{margin-left:auto}.mailbox-health{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:22px 0}.mailbox-health span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.mailbox-health strong{font-size:13px;font-weight:500}.mailbox-explanation{background:#f7f3e8;border-radius:8px;padding:14px;line-height:1.6;font-size:14px;margin-bottom:16px}.mailbox-buttons{display:flex;flex-wrap:wrap;gap:9px;margin-top:18px}.mailbox-panel p.small{margin-top:14px;line-height:1.6}.mailbox-panel p a{text-decoration:underline}.mailbox-empty{padding:25px 0;color:var(--muted)}.mailbox-empty h3{margin:12px 0;color:var(--green)}.mailbox-empty p{font-size:14px;line-height:1.6}
|
||||
@media(max-width:700px){.mailbox-health{grid-template-columns:1fr}.mailbox-title .status{margin-left:0}.mailbox-detail{padding:16px}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user