Repository navigation
Expand file tree
/
Copy pathoffboarding_command_builder.html
More file actions
909 lines (823 loc) · 47.1 KB
/
Copy pathoffboarding_command_builder.html
File metadata and controls
909 lines (823 loc) · 47.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offboarding Command Builder</title>
<style>
:root {
--bg: #f4f7f6;
--panel: #ffffff;
--ink: #2c3e50;
--muted: #7f8c8d;
--accent: #2980b9;
--accent-strong: #1f6391;
--line: #e5e9ec;
--ok: #27ae60;
--warn: #e67e22;
--bad: #c0392b;
--bad-bg: #fdecea;
--bad-border: #e6b0aa;
--code-bg: #1e272e;
--code-fg: #0be881;
--code-fg-bad: #ff6b6b;
}
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg);
color: var(--ink);
max-width: 980px;
margin: 32px auto;
padding: 0 20px 60px;
line-height: 1.45;
}
h1 { color: var(--ink); border-bottom: 2px solid var(--accent); padding-bottom: 10px; margin-bottom: 6px; }
.lede { color: var(--muted); margin-top: 0; }
.container { background: var(--panel); padding: 24px 28px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
.banner {
background: #fef9e7;
border-left: 4px solid #f4d03f;
padding: 10px 14px;
border-radius: 4px;
margin: 14px 0 22px;
font-size: 14px;
}
.banner strong { color: #7d6608; }
.section { margin-top: 26px; }
.section h2 {
font-size: 16px;
color: var(--accent);
margin: 0 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
font-weight: 600;
}
.section.disabled { opacity: 0.45; pointer-events: none; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.row-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.field { margin-bottom: 12px; }
label.field-label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 4px; }
.hint { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 4px; }
input[type="email"], input[type="text"] {
width: 100%;
padding: 8px 10px;
border: 1px solid #cfd6db;
border-radius: 4px;
font: inherit;
font-size: 14px;
}
input[type="email"]:focus, input[type="text"]:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
input.invalid { border-color: var(--bad); background: #fef5f4; }
.check, .radio {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 14px;
padding: 4px 0;
}
.check input, .radio input { margin-top: 3px; cursor: pointer; }
.check label, .radio label { cursor: pointer; user-select: none; }
.check-desc { display: block; color: var(--muted); font-size: 12px; margin-top: 1px; }
.field-hint { display: block; color: var(--muted); font-size: 12px; margin-top: 4px; }
.mode-radios { display: flex; gap: 18px; flex-wrap: wrap; }
.mode-radios .radio { padding: 6px 0; }
.scorched-panel {
margin-top: 14px;
background: var(--bad-bg);
border: 1px solid var(--bad-border);
border-left: 4px solid var(--bad);
padding: 14px 16px;
border-radius: 4px;
}
.scorched-panel h3 { color: var(--bad); margin: 0 0 8px; font-size: 15px; }
.scorched-panel p { margin: 6px 0; font-size: 13px; }
.validation {
margin-top: 22px;
padding: 12px 14px;
border-radius: 4px;
font-size: 13px;
border: 1px solid transparent;
}
.validation.ok { background: #eafaf1; border-color: #abebc6; color: #186a3b; }
.validation.bad { background: var(--bad-bg); border-color: var(--bad-border); color: #922b21; }
.validation ul { margin: 6px 0 0 18px; padding: 0; }
.validation li { margin: 2px 0; }
.output-wrap { margin-top: 18px; }
textarea {
width: 100%;
height: 110px;
padding: 14px;
font-family: 'Courier New', Courier, monospace;
background: var(--code-bg);
color: var(--code-fg);
border: none;
border-radius: 4px;
resize: vertical;
font-size: 14px;
}
textarea.danger { color: var(--code-fg-bad); }
.button-row { margin-top: 12px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
button {
background: var(--accent);
color: white;
border: none;
padding: 9px 18px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: background 0.15s;
}
button:hover:not(:disabled) { background: var(--accent-strong); }
button:disabled { background: #b0bec5; cursor: not-allowed; opacity: 0.8; }
button.secondary { background: #ecf0f1; color: var(--ink); }
button.secondary:hover:not(:disabled) { background: #d9e0e3; }
button.danger { background: var(--bad); }
button.danger:hover:not(:disabled) { background: #962a1f; }
.toggle-inline { font-size: 13px; display: flex; align-items: center; gap: 6px; margin-left: auto; }
.toggle-inline select { padding: 6px 8px; font-size: 13px; border: 1px solid #cfd6db; border-radius: 4px; background: white; }
.profile-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.profile-row select, .profile-row input[type="text"] { padding: 7px 9px; font-size: 13px; border: 1px solid #cfd6db; border-radius: 4px; }
.profile-row select { min-width: 220px; }
details { margin-top: 12px; }
details summary { cursor: pointer; font-weight: 600; color: var(--accent); font-size: 14px; padding: 4px 0; }
.toast {
visibility: hidden;
position: fixed;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
background: var(--ok);
color: white;
padding: 10px 18px;
border-radius: 4px;
font-size: 14px;
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
z-index: 100;
}
.toast.show { visibility: visible; animation: fadein 0.25s, fadeout 0.4s 2.2s; }
@keyframes fadein { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes fadeout { from { opacity: 1; } to { opacity: 0; } }
.page-footer {
margin-top: 28px;
text-align: center;
font-size: 12px;
color: var(--muted);
padding-bottom: 10px;
}
.page-footer a { color: var(--accent); text-decoration: none; }
.page-footer a:hover { text-decoration: underline; }
@media (max-width: 640px) {
.row, .row-3, .mode-radios { display: flex; flex-direction: column; gap: 6px; }
.profile-row { flex-direction: column; align-items: stretch; }
.profile-row select, .profile-row input[type="text"] { min-width: unset; width: 100%; }
.button-row { flex-direction: column; align-items: stretch; }
.toggle-inline { margin-left: 0; }
}
</style>
</head>
<body>
<div class="container">
<h1>Offboarding Command Builder</h1>
<p class="lede">Build a valid <code>python3 offboard_user.py</code> command without reading the source. Cross-platform — open this file in any browser.</p>
<div class="banner">
<strong>Dry-run is the default.</strong> The script makes no changes unless you select <em>Execute</em> mode (which adds <code>--doit</code>). The Copy button stays disabled until the command is valid.
</div>
<div class="banner">
<strong>Containment always runs (v5.2.0).</strong> In Execute mode there is no combination of options that skips it. Even <code>--backup-drive --no-transfer --no-suspend</code>, which reads like a precautionary backup, still moves the user to the offboarding OU, wipes their recovery email and phone, revokes app passwords and OAuth tokens, signs out every session, scrambles the password and hides them from the directory. That is deliberate — the tool exists so a leaver cannot take data with them mid-migration — but it is irreversible, so choose Execute mode on purpose. The plan the script prints before acting says the same thing.
</div>
<div class="banner">
<strong>Shared Drives are reported, never moved.</strong> Their content belongs to the drive, not to a member, so nothing here backs it up or transfers it. Since v5.2.0 the script lists every Shared Drive the leaver organises and flags any where they are the ONLY organiser — delete that account and no member can add members, change settings or delete the drive again, and recovering it means a super admin taking it over from the Admin console. Add a replacement organiser before you delete anyone.
</div>
<!-- ============== TARGET ============== -->
<div class="section">
<h2>1. User to offboard</h2>
<div class="field">
<label class="field-label" for="user">Target user email <span class="hint">— required, sent as <code>--user</code></span></label>
<input type="email" id="user" placeholder="exiting.employee@yourdomain.com" autocomplete="off" required>
<span class="field-hint">The full Google Workspace email of the account being offboarded. Check the spelling carefully — most operations can't be undone once the script runs.</span>
</div>
</div>
<!-- ============== EXECUTION MODE ============== -->
<div class="section">
<h2>2. Execution mode</h2>
<div class="mode-radios">
<div class="radio">
<input type="radio" name="mode" id="mode-dry" value="dry" checked>
<label for="mode-dry"><strong>Dry run</strong><span class="check-desc">Default. No changes are made — the script reports what it would do without touching anything. Always worth a dry run first to catch missing destinations.</span></label>
</div>
<div class="radio">
<input type="radio" name="mode" id="mode-exec" value="exec">
<label for="mode-exec"><strong>Execute</strong><span class="check-desc">Adds <code>--doit</code>. Changes are made for real. Run a dry run first if you haven't already.</span></label>
</div>
<div class="radio">
<input type="radio" name="mode" id="mode-scorched" value="scorched">
<label for="mode-scorched" style="color:var(--bad)"><strong>Scorched earth</strong><span class="check-desc">DESTRUCTIVE: permanently deletes the Google account. No recovery. Only use this when you're certain the user and all their data are no longer needed.</span></label>
</div>
</div>
<div id="exec-extras" style="display:none; margin-top:10px;">
<div class="check">
<input type="checkbox" id="force">
<label for="force"><strong>--force</strong> — skip all interactive prompts (auto-yes).<span class="check-desc">When set, every non-skipped transfer phase must have a destination — the builder validates this for you. Useful for automation or unattended runs. Exception (v5.0.0): an already-suspended user is only temporarily unsuspended when --unsuspend is explicitly ticked — --force alone never reactivates a suspended account.</span></label>
</div>
</div>
<div class="check" style="margin-top:10px;">
<input type="checkbox" id="allow-admin-account">
<label for="allow-admin-account"><strong>--allow-admin-account</strong> — override the admin-account safety hold. <span class="check-desc">Since v5.4.0 the script refuses to offboard a user who still holds Super Admin or delegated-admin roles: privileged roles survive the password scramble and suspension, and come back to life if the account is ever restored. The right fix is to remove the roles first (the script prints the exact <code>gam print admins</code> / <code>gam delete admin</code> commands). Tick this only when keeping the roles is a deliberate decision — the script never revokes them for you, and <code>--force</code> does NOT imply this flag.</span></label>
</div>
<div id="scorched-panel" class="scorched-panel" style="display:none;">
<h3>⚠ Scorched earth — permanent deletion</h3>
<p>This permanently <strong>DELETES</strong> the user after killing access, removing groups/licences, and suspending. The script refuses any backup or transfer flag alongside it (run those first, then delete), so those sections are disabled here. The read-only pre-flight snapshot still runs.</p>
<p>The script requires <code>--doit</code> and <code>--force</code> (added automatically) and a typed-email confirmation at runtime. This builder also requires you to re-type the email below.</p>
<div class="check" style="margin-top:10px;">
<input type="checkbox" id="allow-orphaned-shared-drives">
<label for="allow-orphaned-shared-drives"><strong>--allow-orphaned-shared-drives</strong> — delete even when it strands a Shared Drive.<span class="check-desc">Since v5.6.0 a scorched-earth run checks the leaver's Shared Drives BEFORE the kill switch and refuses (exit 2) if they are the only organizer of one, or if a drive's membership could not be read at all (v5.6.1). Deleting the sole organizer leaves a drive no member can add people to, change settings on, or delete; recovering it means a super admin taking it over in the Admin console. Because the check runs first, a refusal leaves the account completely untouched, so you can add another organizer and re-run. Tick this only when losing those drives is a deliberate decision.</span></label>
</div>
<div class="field" style="margin-top:10px;">
<label class="field-label" for="scorched-confirm">Re-type the target email to enable the Copy button:</label>
<input type="email" id="scorched-confirm" placeholder="must match the email in section 1" autocomplete="off">
</div>
</div>
</div>
<!-- ============== OPERATION MODE ============== -->
<div id="section-opmode" class="section">
<h2>3. Operation mode</h2>
<div class="row">
<div class="check">
<input type="checkbox" id="no-transfer">
<label for="no-transfer"><strong>--no-transfer</strong><span class="check-desc">Skip ALL transfers, forwarding, aliases, calendar, delegates and auto-reply. Only kill switch, devices, groups, licences, backups and suspension run. Good for accounts that are already empty, or where data retention isn't required.</span></label>
</div>
<div class="check">
<input type="checkbox" id="unsuspend">
<label for="unsuspend"><strong>--unsuspend</strong><span class="check-desc">Temporarily unsuspend an already-suspended user so offboarding can complete. The script re-suspends at the end. Without this flag an already-suspended user is offboarded in limited mode: suspension-dependent steps (deprovision backup codes, turnoff2sv, forwarding, auto-reply) will fail with warnings. With --force, this flag is the only way to opt in — --force alone never unsuspends.</span></label>
</div>
</div>
</div>
<!-- ============== DESTINATIONS ============== -->
<div id="section-dest" class="section">
<h2>4. Transfer destinations</h2>
<div class="field">
<label class="field-label" for="all-to">Default destination for all transfers <span class="hint">— <code>--all-transfer-to</code>. Overridden per-phase below.</span></label>
<input type="email" id="all-to" placeholder="manager@yourdomain.com" autocomplete="off">
<span class="field-hint">Where Drive files, emails, aliases, and calendar access go when no per-phase override is set. Usually the departing user's manager. Leave blank only if you're setting individual destinations for every active phase.</span>
<span class="field-hint">v5.2.0 pre-flight: the script refuses a destination that is the leaver themselves, including one of their own aliases — transferring to self does nothing and forwarding to self is a mail loop. It also refuses a SUSPENDED destination, because a restore into one fails with a generic <code>backendError</code> that reads exactly like rate limiting and can burn hours before it gives up. Both checks run before anything destructive, so a typo cannot half-offboard someone.</span>
</div>
<div class="row">
<div class="field">
<label class="field-label" for="drive-to">Drive override <span class="hint">— <code>--drive-to</code></span></label>
<input type="email" id="drive-to" placeholder="leave blank to use default" autocomplete="off">
<span class="field-hint">Override the Drive destination. Useful if legal or IT should receive the files rather than the manager.</span>
</div>
<div class="field">
<label class="field-label" for="email-to">Email override <span class="hint">— <code>--email-to</code></span></label>
<input type="email" id="email-to" placeholder="leave blank to use default" autocomplete="off">
<span class="field-hint">Override the email migration destination. Requires GYB configured on both source and destination accounts.</span>
</div>
<div class="field">
<label class="field-label" for="alias-to">Alias override <span class="hint">— <code>--alias-to</code></span></label>
<input type="email" id="alias-to" placeholder="leave blank to use default" autocomplete="off">
<span class="field-hint">Override the alias destination. The receiving account must be in the same Google Workspace domain as the alias being transferred.</span>
</div>
<div class="field">
<label class="field-label" for="calendar-to">Calendar override <span class="hint">— <code>--calendar-to</code></span></label>
<input type="email" id="calendar-to" placeholder="leave blank to use default" autocomplete="off">
<span class="field-hint">Override the calendar access destination. This shares the calendar — it does not transfer ownership.</span>
</div>
<div class="field">
<label class="field-label" for="forward-to">Forwarding override <span class="hint">— <code>--forward-to</code>. Group address allowed.</span></label>
<input type="email" id="forward-to" placeholder="user or group address" autocomplete="off">
<span class="field-hint">Set up Gmail forwarding to this address. Can be a group. Only catches new mail — messages that arrived while the account was suspended are not forwarded retroactively.</span>
</div>
<div class="field">
<label class="field-label" for="forward-alias-to">Successor for end-of-run notice <span class="hint">— <code>--forward-alias-to</code>. Group allowed.</span></label>
<input type="email" id="forward-alias-to" placeholder="user, group, or leave blank" autocomplete="off">
<span class="field-hint">Sends a summary notice to this address after the script finishes. Can be a group or the manager. Leave blank to skip the end-of-run email.</span>
</div>
</div>
</div>
<!-- ============== BACKUPS ============== -->
<div id="section-backup" class="section">
<h2>5. External backups</h2>
<div class="row">
<div class="check">
<input type="checkbox" id="backup-drive">
<label for="backup-drive"><strong>--backup-drive</strong><span class="check-desc">Local Drive backup via <code>rclone</code> BEFORE the transfer happens. Requires rclone with a configured Google Drive remote. Can be slow for large drives — allow extra time. <strong>Treat it as a convenience copy, not an archive:</strong> a filesystem cannot hold two files with the same name in one folder, Forms and Sites cannot be exported at all, and files owned by the user but living in someone else's folder are outside the tree rclone walks. Since v5.2.0 the script counts what Drive holds against what reached the disk and names the files rclone dropped, rather than trusting rclone's exit code. Since v5.6.0 that shortfall is classified and a real one FAILS the run (exit 1) and holds licence removal, so a short backup can no longer be mistaken for a good one; Forms and Sites, which have no export format, are recognised as an unavoidable gap and stay a warning. If you need a complete copy, transfer ownership instead.</span></label>
</div>
<div class="check">
<input type="checkbox" id="backup-email">
<label for="backup-email"><strong>--backup-email</strong><span class="check-desc">Local email backup via <code>GYB</code> before migration. This is a raw archive — you can't restore from it directly without additional steps. Requires GYB installed and authorised. Since v5.5.0, when the email migration also runs the mailbox is downloaded ONCE: the migration's retained backup serves as the archive.</span></label>
</div>
</div>
</div>
<!-- ============== SKIP PHASES ============== -->
<div id="section-skip" class="section">
<h2>6. Skip phases</h2>
<div class="row-3">
<div class="check"><input type="checkbox" id="no-snapshot"><label for="no-snapshot"><strong>--no-snapshot</strong><span class="check-desc">Skip the pre-flight JSON snapshot of the user's current state. Handy for audits and rollback reference — only skip it if you're re-running and already have one.</span></label></div>
<div class="check"><input type="checkbox" id="no-devices"><label for="no-devices"><strong>--no-devices</strong><span class="check-desc">Skip mobile/ChromeOS device listing. The script only reports devices — it doesn't wipe them. Skip if device tracking isn't required for this offboarding.</span></label></div>
<div class="check"><input type="checkbox" id="no-drive"><label for="no-drive"><strong>--no-drive</strong><span class="check-desc">Skip Drive ownership transfer. Use when the user has no Drive files, or if you've already handled it separately.</span></label></div>
<div class="check"><input type="checkbox" id="no-email"><label for="no-email"><strong>--no-email</strong><span class="check-desc">Skip GYB email migration. Use when email doesn't need to be transferred, or if GYB isn't set up.</span></label></div>
<div class="check"><input type="checkbox" id="no-alias"><label for="no-alias"><strong>--no-alias</strong><span class="check-desc">Skip alias transfer. Aliases will remain on the departing account until manually removed — they won't be deleted or reassigned automatically.</span></label></div>
<div class="check"><input type="checkbox" id="no-calendar"><label for="no-calendar"><strong>--no-calendar</strong><span class="check-desc">Skip calendar access transfer. Use when the user had no shared calendars worth preserving.</span></label></div>
<div class="check"><input type="checkbox" id="no-forward"><label for="no-forward"><strong>--no-forward</strong><span class="check-desc">Skip Gmail forwarding setup. New mail to the old address will go undelivered after suspension unless a group alias handles it.</span></label></div>
<div class="check"><input type="checkbox" id="no-auto-reply"><label for="no-auto-reply"><strong>--no-auto-reply</strong><span class="check-desc">Skip the out-of-office auto-reply. Only skip this if forwarding alone is enough, or if someone will set the reply manually.</span></label></div>
<div class="check"><input type="checkbox" id="no-delegates"><label for="no-delegates"><strong>--no-delegates</strong><span class="check-desc">Skip delegate cleanup. Delegates are accounts that had access to this inbox — the script removes that access. Skip only if you've handled this manually.</span></label></div>
<div class="check"><input type="checkbox" id="no-suspend"><label for="no-suspend"><strong>--no-suspend</strong><span class="check-desc">Skip the final account suspension. The user's account will remain active after the script finishes. Only use this if suspension will happen separately.</span></label></div>
</div>
</div>
<!-- ============== EMAIL LABELS ============== -->
<div id="section-labels" class="section">
<h2>7. Email label handling</h2>
<div class="row-3">
<div class="radio">
<input type="radio" name="labels" id="labels-ask" value="ask" checked>
<label for="labels-ask"><strong>Let the script ask</strong><span class="check-desc">Omits both flags. The script will prompt you interactively at runtime, or default to strip if <code>--force</code> is set.</span></label>
</div>
<div class="radio">
<input type="radio" name="labels" id="labels-strip" value="strip">
<label for="labels-strip"><strong>--strip-labels</strong><span class="check-desc">Removes all original Gmail labels from migrated mail. All messages land under <code>Migrated/<user></code>. Cleaner for the recipient but loses the original folder structure.</span></label>
</div>
<div class="radio">
<input type="radio" name="labels" id="labels-keep" value="keep">
<label for="labels-keep"><strong>--keep-labels</strong><span class="check-desc">Preserves the original Gmail label structure. <code>Migrated/<user></code> is added on top. Better for audits or when the recipient wants to browse the old inbox as it was.</span></label>
</div>
</div>
</div>
<!-- ============== ADVANCED ============== -->
<details>
<summary>8. Advanced</summary>
<div class="field" style="margin-top:10px;">
<label class="field-label" for="log-dir">Log directory <span class="hint">— <code>--log-dir</code>. Default <code>./logs</code>.</span></label>
<input type="text" id="log-dir" placeholder="./logs" autocomplete="off">
<span class="field-hint">Where the script writes its log file. Defaults to <code>./logs</code> relative to where the script is run. Use an absolute path if you run the script from different directories to avoid logs scattering across your filesystem.</span>
</div>
<div class="field" style="margin-top:10px;">
<label class="field-label" for="backup-dir">Backup directory <span class="hint">— <code>--backup-dir</code>. Default <code>./offboarding_backups</code>.</span></label>
<input type="text" id="backup-dir" placeholder="./offboarding_backups" autocomplete="off">
<span class="field-hint">Where snapshots, mailbox backups and Drive backups are written. A mailbox backup can reach hundreds of gigabytes, so point this at a local disk and <strong>never inside iCloud, OneDrive, Dropbox or Google Drive</strong> — a synced folder will try to upload all of it. Since v5.4.0 a re-run that finds an earlier mailbox backup here offers to resume into it instead of re-downloading (under <code>--force</code> it resumes automatically when the folder is 30 days old or less); since v5.5.0 Drive backups resume the same way, and the mailbox size is checked against free disk space before the download starts.</span>
</div>
<div class="field" style="margin-top:10px;">
<label class="field-label" for="restore-batch-size">Restore batch size <span class="hint">— <code>--restore-batch-size</code>. Default <code>50</code>, must be 1–100.</span></label>
<input type="number" id="restore-batch-size" min="1" max="100" placeholder="50" autocomplete="off">
<span class="field-hint">GYB rejects anything outside 1–100 outright, and it does so only when the restore starts — after the mailbox has already been downloaded, which on a large mailbox is hours. Since v5.2.0 the script checks the value when you launch it instead.</span>
<span class="field-hint">Messages sent per request when restoring mail. Measured on a real mailbox: 1 gives ~1,200 msg/hr, 10 gives ~5,800, 100 gives ~9,100. GYB rejects anything above 100. The script steps this down automatically (100 → 75 → 50 → 25 → 10) if Gmail starts throttling, so the default is a safe starting point.</span>
</div>
<div class="field" style="margin-top:10px;">
<label class="field-label" for="reuse-email-backup">Reuse an existing mailbox backup <span class="hint">— <code>--reuse-email-backup</code></span></label>
<input type="text" id="reuse-email-backup" placeholder="/path/to/offboarding_backups/mailboxes/user@domain_20260721" autocomplete="off">
<span class="field-hint">Restore-only mode: restores from a backup you already have and skips the download and every other phase. Use it to finish a restore that died part way. Re-running is safe — Gmail discards messages it already holds, so nothing is duplicated. Needs <code>--force</code>.</span>
</div>
</details>
<!-- ============== PROFILES ============== -->
<div class="section">
<h2>9. Profiles <span class="hint">— saved in your browser only</span></h2>
<p style="font-size:13px; color:var(--muted); margin: 0 0 10px;">Profiles store your current settings by name in browser localStorage — nothing is sent anywhere. Useful if you regularly offboard to the same destinations. Profiles are browser-specific and won't carry over to a different browser or machine.</p>
<div class="profile-row">
<select id="profile-select">
<option value="">— select a saved profile —</option>
</select>
<button type="button" class="secondary" id="profile-load">Load</button>
<button type="button" class="secondary" id="profile-delete">Delete</button>
<input type="text" id="profile-name" placeholder="profile name to save as">
<button type="button" class="secondary" id="profile-save">Save</button>
</div>
</div>
<!-- ============== VALIDATION ============== -->
<div id="validation" class="validation ok" role="status" aria-live="polite">
Ready.
</div>
<!-- ============== OUTPUT ============== -->
<div class="output-wrap">
<label class="field-label" for="out">Generated command</label>
<textarea id="out" readonly></textarea>
<div class="button-row">
<button type="button" id="copy" disabled>Copy command</button>
<button type="button" class="secondary" id="reset">Reset form</button>
<label class="toggle-inline">
<input type="checkbox" id="use-python2">
Use <code>python</code> instead of <code>python3</code>
</label>
<label class="toggle-inline" for="command-shell">
Command shell
<select id="command-shell">
<option value="cmd">Command Prompt (cmd.exe)</option>
<option value="powershell">PowerShell</option>
<option value="posix">macOS / Linux shell</option>
</select>
</label>
</div>
<span class="field-hint" style="margin-top:6px;">Only tick "Use python" if your system's <code>python</code> command points to Python 3. On most modern systems <code>python</code> is Python 2 or absent — use <code>python3</code> unless you know otherwise.</span>
</div>
</div>
<div id="toast" class="toast">Command copied</div>
<footer class="page-footer">
Part of <a href="https://github.com/PaulOgier/GoogleWorkspaceScripts" target="_blank" rel="noopener">GoogleWorkspaceScripts</a> by <a href="https://osh.co.za" target="_blank" rel="noopener">Outsource House</a>
</footer>
<script>
(() => {
'use strict';
// Workspace addresses do not need the full RFC 5322 grammar. Restricting
// input to the common unquoted form also rejects shell control characters.
const EMAIL_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]*@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
const PROFILE_KEY = 'offboarding_builder_profiles_v1';
const SKIP_FLAGS = [
'no-snapshot','no-devices','no-drive','no-email','no-alias',
'no-calendar','no-forward','no-auto-reply','no-delegates','no-suspend'
];
const $ = (id) => document.getElementById(id);
// Map a skip flag id -> the transfer phase it disables, so we know which
// destination overrides are still required. Snapshot/devices/delegates/
// suspend/forward-auto-reply have no per-phase destination requirement
// (forward falls back to --all-transfer-to which we still check globally).
const PHASE_DEST = [
{ phase: 'Drive', skipId: 'no-drive', overrideId: 'drive-to' },
{ phase: 'Email', skipId: 'no-email', overrideId: 'email-to' },
{ phase: 'Alias', skipId: 'no-alias', overrideId: 'alias-to' },
{ phase: 'Calendar', skipId: 'no-calendar', overrideId: 'calendar-to' },
{ phase: 'Forward', skipId: 'no-forward', overrideId: 'forward-to' },
];
const out = $('out');
const copyBtn = $('copy');
const validationEl = $('validation');
const scorchedPanel = $('scorched-panel');
const execExtras = $('exec-extras');
const destSection = $('section-dest');
const skipSection = $('section-skip');
const opmodeSection = $('section-opmode');
const backupSection = $('section-backup');
const labelsSection = $('section-labels');
function getMode() {
return document.querySelector('input[name="mode"]:checked').value;
}
function getLabelMode() {
return document.querySelector('input[name="labels"]:checked').value;
}
function trimmed(id) { return $(id).value.trim(); }
function getShell() { return $('command-shell').value; }
function quoteArg(value) {
const shell = getShell();
if (shell === 'cmd') {
// Windows filenames cannot contain a double quote. Percent and bang are
// rejected separately because cmd.exe expands them even inside quotes.
if (!/[\s&|<>^()]/.test(value)) return value;
// Double trailing backslashes so CommandLineToArgvW does not treat the
// closing quote as escaped (for example: "C:\\Backup Folder\\").
const trailing = value.match(/\\+$/);
const suffix = trailing ? trailing[0] : '';
const body = suffix ? value.slice(0, -suffix.length) : value;
return `"${body}${suffix}${suffix}"`;
}
if (shell === 'powershell') {
return `'${value.replace(/'/g, "''")}'`;
}
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function pathError(id, flag) {
const value = trimmed(id);
if (!value) return null;
if (/[\r\n\0]/.test(value)) return `${flag} cannot contain a line break or null character.`;
if (getShell() === 'cmd' && /["%!]/.test(value)) {
return `${flag} cannot contain double quotes, percent signs, or exclamation marks in Command Prompt mode. Use an expanded absolute path or select another shell.`;
}
return null;
}
function validEmailOrEmpty(id) {
const v = trimmed(id);
return v === '' || EMAIL_RE.test(v);
}
function refreshFieldStyles() {
['user','all-to','drive-to','email-to','alias-to','calendar-to',
'forward-to','forward-alias-to','scorched-confirm'].forEach(id => {
const el = $(id);
if (!el) return;
const v = el.value.trim();
if (v === '') { el.classList.remove('invalid'); return; }
el.classList.toggle('invalid', !EMAIL_RE.test(v));
});
}
function applyModeUI() {
const mode = getMode();
execExtras.style.display = (mode === 'exec') ? 'block' : 'none';
scorchedPanel.style.display = (mode === 'scorched') ? 'block' : 'none';
// Scorched earth makes destinations/skip/backup/labels meaningless
const scorched = (mode === 'scorched');
destSection.classList.toggle('disabled', scorched || $('no-transfer').checked);
skipSection.classList.toggle('disabled', scorched);
backupSection.classList.toggle('disabled', scorched);
labelsSection.classList.toggle('disabled', scorched);
opmodeSection.classList.toggle('disabled', scorched);
}
function destinationFor(phase) {
// Returns the effective destination for a phase, considering per-phase
// override falling back to --all-transfer-to.
const overrideId = PHASE_DEST.find(p => p.phase === phase).overrideId;
return trimmed(overrideId) || trimmed('all-to');
}
function validate() {
const errors = [];
const notes = [];
const mode = getMode();
const userVal = trimmed('user');
// Always required
if (!userVal) {
errors.push('Target user email is required (--user).');
} else if (!EMAIL_RE.test(userVal)) {
errors.push('Target user email is not a valid email address.');
}
// Validate all email fields' format (if not empty)
[
['all-to', '--all-transfer-to'],
['drive-to', '--drive-to'],
['email-to', '--email-to'],
['alias-to', '--alias-to'],
['calendar-to', '--calendar-to'],
['forward-to', '--forward-to'],
['forward-alias-to', '--forward-alias-to'],
].forEach(([id, flag]) => {
if (!validEmailOrEmpty(id)) errors.push(`${flag} is not a valid email address.`);
});
const batchSizeVal = trimmed('restore-batch-size');
if (mode !== 'scorched') {
if (batchSizeVal && (!/^[0-9]+$/.test(batchSizeVal) || +batchSizeVal < 1 || +batchSizeVal > 100)) {
errors.push('Restore batch size must be a whole number from 1 to 100. GYB rejects anything above 100.');
} else if (batchSizeVal && +batchSizeVal === 1) {
notes.push('Batch size 1 uploads one message per request and only saves progress at the end, so a crash restarts from the first message. Use 10 or more unless you have a reason not to.');
}
}
const reuseVal = trimmed('reuse-email-backup');
const pathIds = ['log-dir'];
if (mode !== 'scorched' && !reuseVal) pathIds.push('backup-dir');
if (mode !== 'scorched') pathIds.push('reuse-email-backup');
pathIds.forEach(id => {
const flag = '--' + id;
const error = pathError(id, flag);
if (error) errors.push(error);
});
if (reuseVal) {
if (mode === 'scorched') {
notes.push('--reuse-email-backup is omitted in Scorched earth mode.');
} else {
if (!trimmed('email-to') && !trimmed('all-to')) {
errors.push('--reuse-email-backup requires --email-to or --all-transfer-to.');
}
if (mode === 'exec' && !$('force').checked) {
errors.push('--reuse-email-backup needs --force in Execute mode because a restore-only run cannot answer interactive prompts.');
}
if (mode === 'dry') {
notes.push('--reuse-email-backup will run a dry-run preview of restore-only mode.');
}
notes.push('Restore-only mode: the mailbox restore runs and every other phase is skipped.');
}
}
// Label radio mutual exclusion is enforced by radio inputs; nothing to check.
if (mode === 'exec') {
const forced = $('force').checked;
const noTransfer = $('no-transfer').checked;
if (forced && noTransfer) {
notes.push('--no-transfer is set: destination checks are skipped because no transfer phases will run.');
} else if (forced && !noTransfer) {
// Mirror the script's preflight_destinations() — every non-skipped
// transfer phase must have a resolvable destination.
PHASE_DEST.forEach(({ phase, skipId }) => {
if ($(skipId).checked) return;
if (!destinationFor(phase)) {
errors.push(`--force is set but no destination resolves for the ${phase} phase. Set --${PHASE_DEST.find(p=>p.phase===phase).overrideId} or --all-transfer-to.`);
}
});
}
} else if (mode === 'scorched') {
const confirmVal = trimmed('scorched-confirm');
if (!userVal) {
// Already reported above.
} else if (confirmVal !== userVal) {
errors.push('Scorched-earth confirmation does not match the target email yet.');
}
}
return { errors, notes };
}
function buildCommand() {
const parts = [];
parts.push($('use-python2').checked ? 'python' : 'python3');
parts.push('offboard_user.py');
const userVal = trimmed('user');
if (userVal) parts.push('--user', quoteArg(userVal));
const mode = getMode();
if (mode === 'exec') {
parts.push('--doit');
if ($('force').checked) parts.push('--force');
} else if (mode === 'scorched') {
parts.push('--doit', '--force', '--scorched-earth');
if ($('allow-orphaned-shared-drives').checked) {
parts.push('--allow-orphaned-shared-drives');
}
}
if ($('allow-admin-account').checked) parts.push('--allow-admin-account');
const reuseBackup = trimmed('reuse-email-backup');
const restoreOnly = mode !== 'scorched' && !!reuseBackup;
if (mode !== 'scorched') {
if (!restoreOnly && $('no-transfer').checked) parts.push('--no-transfer');
if (!restoreOnly && $('unsuspend').checked) parts.push('--unsuspend');
// Destinations only meaningful outside scorched-earth and (mostly)
// outside --no-transfer; the script still accepts them but they're
// ignored under --no-transfer, so we omit them for cleanliness.
if (restoreOnly) {
const emailTo = trimmed('email-to');
const allTo = trimmed('all-to');
if (emailTo && EMAIL_RE.test(emailTo)) parts.push('--email-to', quoteArg(emailTo));
else if (allTo && EMAIL_RE.test(allTo)) parts.push('--all-transfer-to', quoteArg(allTo));
} else if (!$('no-transfer').checked) {
const destMap = [
['all-to', '--all-transfer-to'],
['drive-to', '--drive-to'],
['email-to', '--email-to'],
['alias-to', '--alias-to'],
['calendar-to', '--calendar-to'],
['forward-to', '--forward-to'],
];
destMap.forEach(([id, flag]) => {
const v = trimmed(id);
if (v && EMAIL_RE.test(v)) parts.push(flag, quoteArg(v));
});
}
// --forward-alias-to is end-of-run guidance; safe regardless of --no-transfer.
const fwdAlias = trimmed('forward-alias-to');
if (!restoreOnly && fwdAlias && EMAIL_RE.test(fwdAlias)) parts.push('--forward-alias-to', quoteArg(fwdAlias));
if (!restoreOnly && $('backup-drive').checked) parts.push('--backup-drive');
if (!restoreOnly && $('backup-email').checked) parts.push('--backup-email');
if (!restoreOnly) {
SKIP_FLAGS.forEach(id => {
if ($(id).checked) parts.push('--' + id);
});
}
const labelMode = getLabelMode();
if (labelMode === 'strip') parts.push('--strip-labels');
else if (labelMode === 'keep') parts.push('--keep-labels');
}
const logDir = trimmed('log-dir');
if (logDir) parts.push('--log-dir', quoteArg(logDir));
const backupDir = trimmed('backup-dir');
if (backupDir && !restoreOnly && mode !== 'scorched') parts.push('--backup-dir', quoteArg(backupDir));
const batchSize = trimmed('restore-batch-size');
if (batchSize && mode !== 'scorched') parts.push('--restore-batch-size', batchSize);
if (restoreOnly) parts.push('--reuse-email-backup', quoteArg(reuseBackup));
return parts.join(' ');
}
function render() {
refreshFieldStyles();
applyModeUI();
const { errors, notes } = validate();
const cmd = buildCommand();
out.value = cmd;
const scorched = (getMode() === 'scorched');
out.classList.toggle('danger', scorched);
validationEl.textContent = '';
if (errors.length === 0) {
validationEl.className = 'validation ok';
const msg = document.createElement('span');
msg.textContent = scorched
? 'Confirmed. This command will permanently delete the user.'
: 'Ready — command is valid.';
validationEl.appendChild(msg);
copyBtn.disabled = false;
} else {
validationEl.className = 'validation bad';
const strong = document.createElement('strong');
strong.textContent = 'The command is not ready:';
const ul = document.createElement('ul');
errors.forEach(e => {
const li = document.createElement('li');
li.textContent = e;
ul.appendChild(li);
});
validationEl.appendChild(strong);
validationEl.appendChild(ul);
copyBtn.disabled = true;
}
if (notes.length > 0) {
const noteEl = document.createElement('p');
noteEl.style.cssText = 'margin:6px 0 0; font-size:12px; color:#555;';
noteEl.textContent = notes.join(' ');
validationEl.appendChild(noteEl);
}
}
// ===== Profiles =====
function loadProfiles() {
try { return JSON.parse(localStorage.getItem(PROFILE_KEY) || '{}'); }
catch { return {}; }
}
function saveProfiles(p) {
localStorage.setItem(PROFILE_KEY, JSON.stringify(p));
}
function refreshProfileSelect() {
const sel = $('profile-select');
const current = sel.value;
const profiles = loadProfiles();
sel.innerHTML = '<option value="">— select a saved profile —</option>';
Object.keys(profiles).sort().forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
sel.appendChild(opt);
});
if (current && profiles[current]) sel.value = current;
}
function snapshotForm() {
const snap = {
mode: getMode(),
labels: getLabelMode(),
force: $('force').checked,
use_python2: $('use-python2').checked,
command_shell: getShell(),
text: {},
bool: {},
};
[
'user','all-to','drive-to','email-to','alias-to','calendar-to',
'forward-to','forward-alias-to','log-dir','backup-dir',
'restore-batch-size','reuse-email-backup'
].forEach(id => { snap.text[id] = $(id).value; });
['no-transfer','unsuspend','backup-drive','backup-email','allow-admin-account',
'allow-orphaned-shared-drives', ...SKIP_FLAGS]
.forEach(id => { snap.bool[id] = $(id).checked; });
return snap;
}
function restoreForm(snap) {
if (!snap) return;
document.querySelector(`input[name="mode"][value="${snap.mode || 'dry'}"]`).checked = true;
document.querySelector(`input[name="labels"][value="${snap.labels || 'ask'}"]`).checked = true;
$('force').checked = !!snap.force;
$('use-python2').checked = !!snap.use_python2;
$('command-shell').value = ['cmd', 'powershell', 'posix'].includes(snap.command_shell) ? snap.command_shell : 'cmd';
Object.entries(snap.text || {}).forEach(([id, v]) => { if ($(id)) $(id).value = v; });
Object.entries(snap.bool || {}).forEach(([id, v]) => { if ($(id)) $(id).checked = !!v; });
// Clear the scorched-earth confirm field — never restore it.
$('scorched-confirm').value = '';
}
function showToast(msg) {
const t = $('toast');
t.textContent = msg;
t.className = 'toast show';
setTimeout(() => { t.className = 'toast'; }, 2600);
}
// ===== Event wiring =====
document.addEventListener('input', render);
document.addEventListener('change', render);
$('copy').addEventListener('click', async () => {
if (copyBtn.disabled) return;
try {
await navigator.clipboard.writeText(out.value);
showToast('Command copied');
} catch {
out.select();
document.execCommand('copy');
showToast('Command copied');
}
});
$('reset').addEventListener('click', () => {
if (!confirm('Reset all fields to defaults?')) return;
document.querySelectorAll('input[type="email"], input[type="text"], input[type="number"]').forEach(el => el.value = '');
document.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false);
document.querySelector('input[name="mode"][value="dry"]').checked = true;
document.querySelector('input[name="labels"][value="ask"]').checked = true;
$('command-shell').value = 'cmd';
render();
});
$('profile-save').addEventListener('click', () => {
const name = trimmed('profile-name');
if (!name) { alert('Enter a profile name first.'); return; }
const profiles = loadProfiles();
if (profiles[name] && !confirm(`Overwrite existing profile "${name}"?`)) return;
profiles[name] = snapshotForm();
saveProfiles(profiles);
$('profile-name').value = '';
refreshProfileSelect();
$('profile-select').value = name;
showToast(`Profile "${name}" saved`);
});
$('profile-load').addEventListener('click', () => {
const name = $('profile-select').value;
if (!name) { alert('Select a profile to load.'); return; }
const profiles = loadProfiles();
if (!profiles[name]) { alert(`Profile "${name}" not found.`); return; }
restoreForm(profiles[name]);
render();
showToast(`Profile "${name}" loaded`);
});
$('profile-delete').addEventListener('click', () => {
const name = $('profile-select').value;
if (!name) { alert('Select a profile to delete.'); return; }
if (!confirm(`Delete profile "${name}"?`)) return;
const profiles = loadProfiles();
delete profiles[name];
saveProfiles(profiles);
refreshProfileSelect();
showToast(`Profile "${name}" deleted`);
});
// Initial paint
refreshProfileSelect();
render();
})();
</script>
</body>
</html>