MukeSoft · Operator & User Manual

EmailInsightAI

A multi-tenant Laravel application that syncs company mailboxes from Gmail and Microsoft 365, reads every incoming mail with Gemini, and turns what it finds into work: a sentiment, a recommended next action, a buying signal, a category, an owning team, an automatic acknowledgement, and a reminder when nobody answers.

Framework
Laravel 12 · PHP 8.2
Datastore
MariaDB / MySQL
AI model
Gemini 2.5 Flash
Mail sources
Gmail · MS Graph
Tenant DBs
One per client
Scheduled jobs
7 recurring

SECTION 1What the system does

EmailInsightAI sits between a company's shared mailboxes — info@, sales@, support@ and each employee's own address — and the people who have to act on what arrives in them. It never becomes the mail client. Mail stays in Gmail or Microsoft 365; the application keeps a synchronised copy, enriches it, and gives a team a working surface over the top.

1.1The problem it addresses

A shared inbox taking a few hundred mails a day has four failure modes, and the product is organised around them:

  • Nobody knows what is in there. Every mail is read by the AI and labelled with a sentiment, a category, a recommended next action and a lead signal — all filterable from one toolbar.
  • Nobody owns anything. Mail is routed to a department automatically, from the category the classifier already assigned, and can be handed to one person by override.
  • Senders wait for an acknowledgement. Auto Reply answers defined categories from admin-approved templates, behind a confidence floor and per-recipient daily caps.
  • Things fall through. Follow-up Reminder raises a task for any mail owed an answer, chases it on a schedule, and closes itself when a reply is detected.

1.2Who uses it

Three roles, distinguished by columns on the user row. There is no roles table — users.user_level, users.auth_id and users.is_department_head are the whole access model.

Role model
Roleuser_levelauth_idCan do
Account admin10Everything. Owns the configuration — categories, templates, vocabularies, routing, follow-up settings. Sees every mailbox under the account and the full sidebar.
Department head1admin's idSigns in, works their own mailbox plus mail routed to their department. Carries is_department_head = 1 and a group_by naming their department.
Employee mailbox2admin's idA synced mailbox, not an interactive account. Mail arrives under it and is visible to the admin, but the person does not sign in.

The login screen enforces this directly: AuthController::login() rejects any user whose user_level is 0 with “Only Admin Or Department Head Have Access” before an OTP is ever issued.

Terminology

Throughout the app and this manual, account means the admin user and everything beneath them; mailbox means one row in users that mail is synced into. Configuration belongs to the account; mail belongs to a mailbox. Department::ownerIdFor() is the single function that resolves a mailbox to its owning account, and everything that reads configuration goes through it — so one account's routing rules can never act on another account's teams.

1.3Technology

laravel/framework ^12.0
Application framework. The console kernel is bound manually in bootstrap/app.php, so the classic app/Console/Kernel.php schedule still applies rather than routes/console.php.
firebase/php-jwt ^6.11
Signs the RS256 assertion used for Google service-account domain-wide delegation. This is how a mailbox is impersonated without the application ever holding its password.
phpmailer/phpmailer ^7.0
Sends the daily summary mail.
phpoffice/phpspreadsheet ^4.4
Bulk employee import and mail-list export, both .xlsx.
Gemini 2.5 Flash
Primary classifier, thread summariser and reply drafter. Falls back to gemini-pro-latest after repeated 503 responses.
Bootstrap 5 + Blade
Server-rendered UI. No SPA and no front-end build step is required to run the application.

1.4System architecture

ENTRY POINTS Browseradmin / dept head Task Schedulerrun-scheduler.batevery 60 seconds HTTP schedule:run APPLICATION Laravel 12 · PHP 8.2 Controllers + Blade Console commands Services AutoReplyService FollowUpService ThreadSummaryService Eloquent models connection: default | clientdb EXTERNAL SERVICES Gmail APIread + send Microsoft Graphread + send Gemini APIclassify SMTPreminders PERSISTENCE which tenant? master DB clients · cron_queue cron_mail_send · outlook_creds connection: master db_name tenant DB (per client) users · messages · follow_ups email_categories · templates … connection: clientdb (set at runtime) Nightly dump 00:01 · mysqldump outside htdocs 14-night retention ON DISK storage/app/email/service-account.json storage/app/email/outlook_account.json Google DWD key · Graph token cache storage/logs/auto_reply.log storage/logs/scheduler.log · worker.log every decision the pipeline made .env config/auto_reply.php · follow_up.php credentials + hard safety limits only
Figure 1 — Deployment topology. Two entry points reach the same application code: a browser, and a Windows Task Scheduler tick running artisan schedule:run every minute. Both resolve a tenant the same way — read clients.db_name from the master database, then point the clientdb connection at it at runtime. The teal path is the one that costs money per mail.

SECTION 2How mail moves through the system

One mail's journey has five stages. Stages 1–3 happen inside app:process-email-queue and cost one Gemini call. Stages 4 and 5 are separate scheduled passes that read the messages table afterwards — a design decision explained below, and the reason the system can enrich mail that arrived before a feature existed.

IN ONE COMMAND — app:process-email-queue, every 10 minutes 1 · Claim cron_queue row top 5 by priority 2 · Fetch Gmail or Graph max 40 per mailbox 3 · Classify Gemini · 1 call 9 JSON fields 4 · Store INSERT INTO messages + Department::decide() assignment is inline — no extra AI call Sync is FORWARD-ONLY: the Gmail query is built as after:<newest stored sent_date>, falling back to “yesterday” on an empty table. Anything older than the newest stored mail is never revisited. Use mail:backfill-gmail to reach back. rows land in messages messages — one row per mail, the system of record SEPARATE SCHEDULED PASSES — each reads the table, none is chained to the sync 5a · Auto Reply every 10 min · Gemini call claim: no auto_reply_logs row writes auto_reply_logs 5b · Follow-ups every 15 min · no AI call reads next_action already stored writes follow_ups 5c · Reminders hourly · SMTP digest one mail per mailbox per run updates remind_at, reminder_count ON DEMAND Thread summary cached per conversation AI reply draft never sends, only drafts Period summary mail_summaries digest cache Auto Reply — Run now
Figure 2 — The mail lifecycle. Only stage 3 and Auto Reply call Gemini. Assignment, follow-up detection and lead banding are all derived from what the classifier already returned, which is why they cost nothing per mail and work retroactively on mail classified months ago.

2.1Why the later stages are decoupled

Auto-reply and follow-up detection could have run inline, immediately after each insert. They deliberately do not, for two reasons stated in the code:

  • Different failure modes should not be entangled. A Gemini outage must cost an acknowledgement, never an inbox sync.
  • A mail would otherwise get exactly one chance. Anything already sitting in messages — every mail that arrived before the feature shipped, or during an outage — would be permanently invisible. Working off the table instead means a later pass still picks it up.

Idempotency is therefore structural rather than flag-based. A message is a candidate for auto-reply only when it has no auto_reply_logs row, and the service claims that row before doing any work; a message is a candidate for a follow-up only when it has no follow_ups row, enforced by a unique key. In both cases the row means this mail has been considered, whatever the outcome — so nothing is evaluated twice, and a task somebody dismissed is not raised again on the next pass.

2.2What the classifier is asked

One prompt, one call, nine fields. The prompt is assembled at runtime from the account's own vocabularies, so a category or sentiment an admin added this morning is in the prompt this afternoon without a deploy.

The classification contract — returned as raw JSON
FieldWritten toMeaning
action_requiredaction_requiredsend-mail when the reply asks a question or needs a response, otherwise no-action. Stored as 1/0 and used by the “Reply pending” filter and as the follow-up fallback trigger.
sentimentis_spam, is_spam_valueA code from the account's sentiments table. The numeric legacy_code goes in is_spam; the word goes in is_spam_value.
categorycategory, is_category_valueA code from email_categories. Drives auto-reply eligibility and department routing.
next_actionnext_action, next_action_sourceThe single most useful thing the recipient should do next. Source is ai when the model answered, inferred when derived from older columns.
is_potential_leadis_potential_leadTrue only when the sender might buy — a vendor selling to you is explicitly not a lead.
lead_typelead_type, lead_sourceA code from lead_types, which also carries the priority band and the score floor.
lead_scorelead_score0–100 confidence that this is a real buying opportunity. Below the type's min_score the mail is not counted as a lead.
lead_reasonlead_reasonOne sentence quoting what in the mail makes it a lead.
summarymail_summary1–2 plain-text sentences, under 300 characters, covering the whole mail. Always required, including for newsletters and notifications.

Two instructions in the prompt matter operationally. The model is told to judge only the newest message — quoted history and On <date>, <person> wrote: blocks are stripped by a regular expression before the call and ignored by instruction after it. And it is told to always return all nine fields with best-guess defaults, because a partial response would leave columns null and silently drop the mail out of every filter.

Failure handling

CodeMeaningWhat the pipeline does
503Model overloadedSleeps 30 seconds and retries on Flash. Still 503 → retries once on gemini-pro-latest. Still failing → aborts this mailbox's run; the next tick tries again.
429Rate limitAborts the run for this mailbox. Free-tier ceilings are 15 requests/minute and 1,500/day, which is why the per-run insert cap is 40.
250Predict API unreachableAborts and logs to MailController_log_cron.txt.
—Empty or non-JSON replyAborts before writing. A malformed classification is never stored as if it were real.
Cost control

Mail inserted by mail:backfill-gmail deliberately leaves the AI columns null. The application treats null as not generated yet and fills summaries lazily when somebody actually opens the mail — so restoring thousands of old mails does not spend thousands of Gemini calls on labels nobody may ever look at.

SECTION 3Multi-tenancy

Each client company gets its own MySQL database. There is no client_id column anywhere in the application tables — separation is physical. A small master database holds the directory of clients and the work queue that decides whose mail is fetched next.

3.1The two connections

master
Fixed database named by MASTER_DB_DATABASE. Holds clients, cron_queue, cron_mail_send, outlook_creds, plus a portal users table and a staging messages table.
clientdb
Declared in config/database.php with an empty database name. At runtime a command sets database.connections.clientdb.database to the client's db_name, calls DB::purge('clientdb') to force a reconnect, and works through that handle.
mysql (default)
What the web application uses. On a single-tenant install this is the same database clientdb would point at.
Consequence

Every service class in app/Services takes an optional ?string $connection constructor argument. Passing 'clientdb' is what makes it operate on a tenant; passing null makes it operate on the default connection. A service instantiated without that argument inside a cron loop will silently read the wrong database.

3.2The work queue

MASTER DATABASE cron_queue client_id · employee_id status · priority progress_count one row = one mailbox to sync SELECT … LIMIT 5 status != 'done' created_at = CURDATE() ORDER BY priority DESC COMMAND app:process-email-queue lookup clients.db_name config(clientdb.database) DB::purge('clientdb') account_type picks Gmail or Graph reconnect TENANT DATABASES acme_dbpriority 5 EmailInsightprocessing northwind_dbpriority 0 ROW LIFECYCLE WITHIN ONE TICK pending claimed processing ok done same run reset to pending, priority 0 failed exception → Job_fail_log.log; row is not retried until tomorrow's row A row that is reset keeps its created_at, so it stays eligible all day; raising priority moves a paying client to the front of the next tick.
Figure 3 — Queue mechanics. The reset at the end of a successful run is what makes one row per mailbox per day sufficient: the row cycles pending → processing → done → pending all day, and zeroing priority on reset stops a boosted client monopolising every subsequent tick.

Two commands consume this queue. app:process-email-queue fetches and classifies mail; app:process-auto-reply-queue uses the same table to decide whose mail is answered next. The scheduled auto-reply job is the queue-driven one precisely so that mailboxes are not treated as equal — a paying client raised up the order is served first, and a mailbox can be taken out of rotation by deleting its row rather than by editing code.

Do not run both auto-reply commands

app:process-auto-replies --all-clients (the sweep) and app:process-auto-reply-queue (the queue) chase the same mail. They cannot double-send — the service claims its log row before doing any work — but the second runner is wasted effort. Pick one; the shipped schedule uses the queue.

3.3Onboarding a new client

  1. Create the tenant database and run the migrations against it.
  2. Insert a row into master.clients with name, db_name, company_name, domain_name, mail_type, status = 'active' and, for Microsoft mail, the tenantId.
  3. For Microsoft mail, add the matching master.outlook_creds row (tenantId, clientId, clientSecret).
  4. Register the admin user in the tenant database with auth_id = 0 and user_level = 1.
  5. Add employee mailboxes — individually via Add Email, or in bulk via the Excel upload.
  6. Insert one master.cron_queue row per mailbox that should be synced.

SECTION 4Accounts, access and mailbox connection

4.1The columns that define a user

ColumnMeaning
auth_id0 marks the account admin. Any other value is the admin's id, making this row one of their mailboxes.
user_level1 may sign in (admin or department head). 2 is a synced mailbox. 0 is explicitly refused at login.
account_type0 = Google Workspace (Gmail API). 1 = Microsoft 365 (Graph). This single column selects the entire fetch and send path.
is_department_head1 for the one member of a department who owns routed mail. Exactly one per department.
group_byThe departments.id this user belongs to, or 0.
is_service_account1 when the mailbox is reached through domain-wide delegation rather than a stored app password.
app_email_passLaravel-encrypted app password. Only used on the legacy IMAP path; service-account mailboxes never need it.
is_loginConnection-health flag, set by the Settings connection test: 0 healthy, 1 failing.
Known data hazard

users.group_by is a bare integer with no foreign key. It can name a department belonging to a different account. Never treat team membership as proof of account ownership — resolve the account through Department::ownerIdFor() and check that the department's user_id matches before acting on the membership.

4.2Signing in

Login is two-factor by mail, and the OTP is only issued after the role check passes.

  1. POST /login validates the credentials with Auth::validate() — note that this does not start a session.
  2. A user with user_level = 0 is refused here.
  3. A six-digit OTP is written to user_otps with a five-minute expiry (one row per user, upserted) and mailed to the address.
  4. The browser is redirected to /otp-verify/{user}.
  5. POST /otp-verify checks the code and expiry, then logs the session in. POST /resend-otp issues a fresh code.

Password recovery

GET/POST /forgot-password
Requests a reset link; the token is stored in password_reset_tokens.
GET/POST /reset-password
Consumes the token and sets a new password.
GET/POST /set-password
First-time password setup for a newly added department head, which also records their department assignment.

4.3Connecting a mailbox

Google Workspace — account_type = 0

The application holds a service-account key at storage/app/email/service-account.json and uses domain-wide delegation. For each mailbox it mints a JWT with sub set to that address, signs it RS256, exchanges it at oauth2.googleapis.com/token for a one-hour access token, and calls the Gmail API as that user. No mailbox password is ever stored.

ScopeUsed for
gmail.readonlyFetching mail in the sync pipeline.
gmail.modifySending replies and auto-replies.

Both scopes must be authorised against the service account's client ID in the Google Workspace admin console. A mailbox outside the delegated domain will fail the connection test with a 401 no matter what is configured locally.

Microsoft 365 — account_type = 1

Client-credentials flow against Microsoft Graph. Credentials come from config/auto_reply.php (OUTLOOK_TENANT_ID, OUTLOOK_CLIENT_ID, OUTLOOK_CLIENT_SECRET) or, per tenant, from master.outlook_creds. The issued token is cached at storage/app/email/outlook_account.json.

Verifying a connection

Settings → Connection test (GET /settings/connection-test) runs the real check for whichever path the account type selects and writes the result back to users.is_login. Use it before blaming the cron: a mailbox that cannot be reached produces an empty sync with no obvious error on the mail list.

SECTION 5Using the app, screen by screen

Navigation is a single left sidebar shared by every screen. Entries marked admin only are hidden when auth_id != 0.

Sidebar map
EntryRoutePurpose
Dashboard/dashboardVolume, sentiment split, categories, leads, turnaround, top senders.
Email Inbox/mail-info/{user?}The mail list and the filter toolbar. Carries the unread badge.
Employees (admin)/employeeEvery mailbox under the account, with per-mailbox counts.
Departments (admin)/departmentsCreate, rename and delete teams.
Assign Department/eassign-departmentPut people into teams and name each team's head.
Department List/admin-group/{id?}Heads and their members.
Email Settings/set_emailImportant-sender lists and inbox groups.
Add Email (admin)/add-emailRegister one more mailbox.
Auto Reply/auto-replySettings, categories, templates and the decision log.
Sentiments/sentimentsThe mood vocabulary the classifier may return.
Next Actions/next-actionsThe action vocabulary, and the follow-up windows keyed to it.
Lead Types/lead-typesBuying signals, score floors and priority bands.
Mail Routing/mail-routingWhich team owns which category.
Follow-ups/follow-upsThe queue of mail owed an answer. Carries the overdue badge.
Domains/domainThe account's own domain, used to tell internal mail from external.
Settings/settingsProfile, password, app password, AI prompt, connection test.

5.1Dashboard

Everything is scoped to the signed-in user's mailbox and follows the same counting rule as the mail list, so the dashboard and the filter chips never disagree. Mail the user sent themselves is counted everywhere except the two reply-tracking figures, the turnaround histogram, “Mail received” and “Top senders” — you never owe yourself a reply.

Volume series
Mail received per day over the recent window.
Sentiment split
Doughnut over the account's own sentiment vocabulary, not a fixed three-way split.
Category split
Resolved against the account's email_categories, including legacy-coded mail via ai_aliases.
Lead panel
Totals by priority band — High, Medium, Low — from lead_types.priority and each type's min_score floor.
Reply turnaround
Median and distribution of the gap between an incoming mail and its reply.
Top senders
Who writes in most, incoming only.

On load the dashboard also runs a live connection check for the signed-in mailbox and sets is_app_pwd; a failing check surfaces the app-password prompt rather than silently showing an empty dashboard.

5.2Email Inbox and the filter toolbar

Every filter is the same route — GET /mails/{type}/{user_id?} — so each is bookmarkable and shareable. The {type} segment is resolved against five vocabularies in a fixed order, and the first that claims it wins:

  1. Sentiments — claimed the parameter first historically, so tried first.
  2. Next actions — prefix action_.
  3. Lead types — prefix lead_, plus the three priority bands and leads.
  4. Email categories — prefix cat_.
  5. Assignment — prefix assigned_, tried last so it can never shadow a slug that was bookmarkable before the feature shipped.

Anything unclaimed falls through to the fixed filters. The four vocabulary controllers each reserve the other prefixes, so an admin cannot create a category slug that shadows an assignment listing.

Filter slugs
SlugGroupShows
allFixedEvery mail in the mailbox.
unread / readFixedmessage_status 0 or 1.
send-mailFixedReply pending: action_required = 1, not spam-labelled, not promotion, not yet replied, still unread.
replied_mailFixedMail an outgoing message has been matched to.
promotionFixedis_promotion != 0, unread.
search-{groupId}FixedMail from every sender in that inbox group.
sales / amcLegacyThe original two-bucket split, kept working through legacy_code.
is_not_spam · neutral · is_spamSentimentPositive, Neutral, Negative. The odd names are the original column values, preserved so old bookmarks survive.
sentiment_angry · sentiment_complaint · …SentimentEvery additional mood an admin has defined.
action_respond_immediatelyNext actionSender is waiting right now.
action_escalate_manager · action_escalate_technicalNext actionNeeds a decision, or needs an engineer.
action_call_customer · action_send_quotation · action_schedule_demo · action_follow_upNext actionThe remaining standard actions.
leadsLeadEverything scoring above its type's floor.
lead_priority_high / _medium / _lowLeadThe three bands. Reserved slugs — no lead type may claim them.
lead_purchase_requirement · lead_quotation_request · lead_pricing_request · lead_demo_request · lead_meeting_request · lead_renewal_upsell · lead_product_enquiryLeadOne per shipped lead type.
cat_new_lead · cat_pricing_enquiry · cat_demo_request · cat_product_enquiry · cat_complaint · cat_support_request · cat_payment_billing · cat_existing_customer · cat_vendor · cat_spam · cat_general_enquiryCategoryOne per shipped category.
assigned_meAssignmentThe one listing that crosses mailboxes. Mail routed to you, wherever it landed.
assigned_anyAssignmentOwned by somebody, any team.
assigned_noneAssignmentClassified, routable, and nobody has it. The queue worth watching.
assigned_dept_{id}AssignmentOne team's queue. Keyed by id, not name, so a rename never breaks a bookmark.
Why assigned_me spans mailboxes

An employee's work does not arrive in their own mailbox. Mail to info@ is synced under that mailbox's user_id, and routing it to Support does not move it. A listing scoped to the signed-in user would therefore be empty for exactly the people the feature serves, so this one query runs across Department::mailboxIdsFor() instead.

5.3Reading and replying

GET /mails-show/{email_id}
Full mail view; opening it marks the mail read.
GET /mails-show-single/{email_id}
Single-mail view used from the employee drill-down.
GET /reply-mail/{id}/{user?}
The reply composer.
POST /reply-mail/{id}/{user?}
Sends through Gmail as the mailbox owner.
POST /reply-OutlookReply/{id}/{user?}
Sends through Microsoft Graph.
POST /reply-draft/{id}/{user?}
AI reply draft. Writes messages.reply_draft and never sends. Sending stays on the two routes above, always with a human pressing the button.

The draft is sanitised before storage — the HTML it produces is stripped to a safe subset rather than trusted, because it is displayed inside the composer.

5.4Email Summary

Two different things share the word “summary”, and it is worth keeping them apart:

FeatureStored inWhat it is
Per-mail summarymessages.mail_summaryThe one-or-two sentence summary field from the classification call. Generated with the mail, or lazily on first view for backfilled mail.
Thread summarythread_summariesA whole conversation reduced to five sections plus a status. Generated on demand, cached per conversation, and marked stale when a new reply arrives.
Period summarymail_summariesA digest across a date range and optional domain, opened from the toolbar modal. Cached under an opaque summary_key.

The thread summary's five sections are fixed: Customer requirement, Previous discussion, Current status, Pending actions, Next step. The status pill is one of Open Waiting on us Waiting on customer Resolved No action needed, defaulting to Open if the model returns anything else.

Unlike the per-mail summary, the thread summary asks Gemini for JSON, not HTML, validates it against a fixed schema and renders it through Blade — so it cannot inject anything into the page. Routes are keyed on the numeric messages.id rather than the provider id, because Microsoft conversation ids contain /, + and =, which do not survive a URL path segment.

5.5Employees, departments and groups

/employee
Every mailbox under the account with a message count. Scope follows user_level: level 2 sees only their own auth_id tree, level 0 sees all.
/departments
Full CRUD on teams. A department carries a department_name, a display department_lable, and an is_deletable guard on the built-in ones.
/eassign-department
Places people into teams and nominates each team's head — the person who becomes the named owner of mail routed to that team.
/admin-group/{id?}
Heads and their members. /inboxgroup/view-head/{id} drills into one head.
/inbox_groups
Resource CRUD for inbox groups — named lists of important senders. A group becomes filter slug search-{id} on the mail toolbar.
/set_email
Marks senders important (impmail) and assigns them to groups.

5.6Bulk import and export

GET /download-sample
Downloads the import template.
POST /upload-bulk
Creates mailboxes in bulk from an .xlsx. The first three columns must be exactly First Name, Last Name, Email Id, in that order; a mismatch is rejected with the expected order printed back.
POST /download-excel
Exports the current mail listing to .xlsx.

5.7Settings

POST /settings/profile
First name, last name, contact number.
POST /settings/password
Sign-in password. Requires the current one and refuses a repeat of it; minimum six characters.
POST /settings/app-password
Gmail app password for the legacy IMAP path. Validated against Gmail before it is stored — a rejected password is reported, not saved.
POST /settings/ai
A custom prompt (max 200 chars) and/or priority keywords (max 25 chars), stored in prompts.
GET /settings/connection-test
Live mailbox check; writes the outcome to users.is_login.
/domain
The account's own domain. Used to distinguish internal senders — which auto-reply skips by default.

SECTION 6The AI features in detail

Four of these features are admin-editable vocabularies: rows in a table that are injected into the Gemini prompt, written back to messages, rendered as chips, and turned into filters. Adding “Angry” or “Raise a credit note” is an admin action, not a deploy. All four share the same shape.

Shared vocabulary columns
ColumnRole
codeWhat the model returns and what is stored on the message.
nameWhat a person sees on the chip.
descriptionInjected verbatim into the prompt. This is the actual instruction to the model — write it as guidance, including when to prefer this label over a neighbouring one.
ai_aliasesComma-separated older words that map to this row. This is what makes a vocabulary change free: mail classified last year still resolves.
filter_slugThe URL segment. Prefixed per vocabulary so the five cannot collide.
colourOne of slate, green, red, amber, blue, violet.
is_activeInactive rows leave the prompt but existing mail keeps its label.
sort_orderOrder in the prompt — which matters, see below.
Order is an instruction

The two vocabularies order themselves oppositely, on purpose. Sentiments put broad moods first and specific signals last, because the prompt tells the model to prefer the most specific match. Next actions run most-urgent first, because the model is told to pick the first that fits. Reordering a list changes classification behaviour.

6.1Sentiments

Ships with Positive, Neutral, Negative, Angry and Complaint. The first three carry the legacy numeric codes already in the database (1, 3, 0) and the original filter slugs, so seeding them changes nothing about existing rows. New moods get codes from 20 upward. The chosen word lands in messages.is_spam_value and its number in messages.is_spam — a column name that predates the feature and has nothing to do with spam.

6.2Next actions

Eight shipped actions, most urgent first:

CodeNameChosen whenFollow-up window
respond_immediatelyRespond ImmediatelyAn outage, a blocked operation, an expiring deadline, or explicit words like ASAP.4 h
escalate_managerEscalate to ManagerThreatens to leave, demands compensation, disputes a commitment. Somebody with authority must decide, not merely answer.8 h
escalate_technicalEscalate to TechnicalA fault, bug or performance problem needing a diagnosis.24 h
call_customerCall the CustomerAsks to be phoned, or is too tangled to settle over mail.24 h
send_quotationSend QuotationThe reply the sender wants is a price.48 h
schedule_demoSchedule a DemoThe next step is an appointment.48 h
follow_upFollow UpNeeds chasing later rather than answering now.72 h
no_actionNo ActionNothing is being asked of the reader.never

The windows come from config/follow_up.php and can be overridden per account on the follow-up settings screen. An action an admin adds gets default_due_hours (48) until a window is set for it.

6.3Lead detection and priority

A lead type carries two extra columns the other vocabularies do not have:

  • min_score — the floor below which a mail of this type is not counted as a lead at all. A vague product mention scoring 30 against a floor of 50 is stored but not surfaced.
  • priority — the band the lead is placed in: High, Medium or Low.
CodeNameFloorBandReasoning
purchase_requirementPurchase Requirement40HighAlready decided to buy; working out from whom.
quotation_requestQuotation Request45HighWants a document to act on.
pricing_requestPricing Request50HighStill weighing cost.
demo_requestDemo Request50HighThe next step is showing, not telling.
meeting_requestMeeting Request55MediumWants time in the diary, but has asked for nothing a competitor could answer first.
renewal_upsellRenewal or Upsell50HighRenewals lapse on a date — a missed one is revenue gone, not deferred.
product_enquiryProduct Enquiry—LowInterest is real but unformed. The weakest signal.

Priority bands are computed at read time from the type, not stored on the message — so re-banding a lead type immediately re-bands every existing mail of that type, with no migration and no re-classification. The three band slugs are reserved in LeadType::RESERVED_FILTER_SLUGS so no lead type can ever claim one.

6.4Email categories

Categories carry the most weight of any vocabulary, because three separate features read them: auto-reply eligibility, department routing, and the category chips. Eleven ship by default, ordered specific-first with the catch-alls last, and every one maps back through ai_aliases to the words the original prompt emitted.

CodeNameLead signalCovers
NEW_LEADNew LeadyesFirst approach from someone not yet a customer.
PRICING_ENQUIRYPricing EnquiryyesPrice, quotation, rate card, discount, commercial terms.
DEMO_REQUESTDemo RequestyesDemo, walkthrough, trial, presentation.
PRODUCT_ENQUIRYProduct EnquiryyesWhat a product does; details, brochures, availability.
COMPLAINTComplaintnoDissatisfaction or an escalated unresolved issue.
SUPPORT_REQUESTSupport RequestnoProblem with something already bought, including all AMC work — renewals, service schedules, warranty, SLA.
PAYMENT_BILLINGPayment or BillingnoInvoices, reminders, receipts, POs, tax documents.
EXISTING_CUSTOMERExisting CustomernoRoutine correspondence where nothing new is being bought.
VENDORVendornoSomebody selling to this business, or an existing supplier.
SPAMSpamnoUnsolicited bulk, phishing, mass marketing.
GENERAL_ENQUIRYGeneral EnquirynoThe catch-all, and the fallback code when nothing else fits.

6.5Auto Reply

The pipeline is AI names a category → configuration decides everything else. The model never decides whether to send. A category the business has not defined, activated and given an approved template to is never answered, however certain the model is.

A message with no auto_reply_logs row STOPS HERE → status written 1 · Feature on? env + account settings 2 · Sender not a no-reply address? 3 · External sender, or internal allowed? 4 · Classify against this account's categories 5 · Category active AND auto-reply enabled? 6 · Confidence ≥ threshold? 7 · Template exists and is approved? 8 · Under daily cap for this recipient? 9 · Thread not already answered? SEND — render template, deliver, log SENT SKIPPED · feature off SKIPPED · blocked local part SKIPPED · internal sender FAILED · AI error, retried hourly SKIPPED · category not answerable MANUAL_REVIEW · queued for a human SKIPPED · no approved template SKIPPED · daily cap reached ALREADY_SENT Defaults threshold 0.800 daily cap 3 / recipient once per thread: on internal senders: off manual review: on
Figure 4 — The auto-reply funnel. Every gate is configuration an admin controls, and every stop writes a row in auto_reply_logs with the reason. Only gate 4 costs a Gemini call, and only a FAILED there is retried — because a skip was a decision, not an error.

The four screens

/auto-reply
Account settings: master switch, confidence threshold, once-per-thread, manual review below threshold, reply to internal senders, daily limit per recipient, plus the company name, default product name and signature name that templates substitute in.
/auto-reply/categories
Per category: is auto-reply enabled, which template, and an optional confidence threshold that overrides the account default for this category alone.
/auto-reply/templates
Subject and HTML body with {{variable}} placeholders. Status is draft → approved → inactive; only approved templates are ever sent. Preview renders with sample values.
/auto-reply/logs
Every decision, with the AI's raw response, the confidence, the template used and the exact body delivered. Individual entries can be retried.

Template variables

Templates are not Blade. They are a plain substitution over a fixed name list, and every value is HTML-escaped before it lands in the body — an admin writing a template can never execute anything. An unknown or empty placeholder collapses to nothing rather than leaving {{product_name}} visible in a customer's inbox.

PlaceholderFilled with
{{customer_name}}Name of the person who wrote in.
{{customer_email}}Their address.
{{product_name}}Default product name from auto-reply settings.
{{company_name}}Company name from auto-reply settings.
{{category_name}}The category the mail was classified as.
{{original_subject}}Subject of the mail being acknowledged.
{{signature_name}}Name the acknowledgement is signed with.
{{received_date}}Date the mail arrived.

Blocked recipients

These local parts never receive an automated reply, because answering them creates loops: noreply, no-reply, no_reply, donotreply, do-not-reply, mailer-daemon, postmaster, bounce, bounces, notification, notifications, automated, auto-reply, autoreply.

Run now

The inbox toolbar's Run now button (POST /auto-reply/run-now) answers the newest unanswered mail immediately, capped at 10 messages per press. It is for testing a freshly configured template without waiting ten minutes for the next tick.

6.6Mail Routing — automatic assignment

The routing table is the category table: email_categories.assign_department_id. A rule is a category with a team against it, which is why there is no separate rules resource. The whole table is edited in one submit, because routing decisions are read and made together.

No prompt changed to ship this feature, and none should. The classifier already answers the only language question involved — what kind of mail is this — and which team owns a pricing enquiry is not a language question but this business's org chart. So the category is read, not re-asked. That is what makes assignment free: no extra Gemini call, no field that could come back malformed, and it works on mail classified months ago.

Column on messagesHolds
assigned_department_idThe team the category routed to.
assigned_user_idThe department head, or the person a manual override named.
assignment_sourcerule or manual. A manual assignment always outranks the rule.
assigned_atWhen ownership was established.
assigned_byWho overrode, on a manual assignment.

Assign and unassign are POSTs carrying the mail in the request body rather than the path, because they key on messages.email_id — a provider id that, for Microsoft mail, contains characters that do not survive a URL path segment.

Applying a new mapping to old mail

Changing the mapping does not re-route existing mail. Run php artisan mail:assign-existing --dry-run first, then without the flag. This is deliberately a command with a report rather than a side effect of saving a dropdown: re-routing thousands of mails is a visible act. It never touches mail that already has an owner, whatever the source, and it skips promotions and provider-flagged spam.

6.7Follow-up Reminder

Detection costs no AI call. The classifier already said what each mail's reader should do next, and that recommendation is the detection; the settings only decide how long it may go unactioned. A mail with no usable next action is still caught through action_required = 1 — a queue that silently excludes half the mailbox would be worse than none.

What is skipped outright

  • Mail the mailbox sent — that is the answer, not the question.
  • Promotions, and anything the provider labelled spam.
  • Mail already replied to, using the same signal the “Replied Mail” listing uses.
  • Anything whose next action has a null window (no_action out of the box).
mail arrivesnext_action recommended sweep · 15 min open due_at · remind_at set reminder sent → reminder_count++, remind_at += repeat_every_hours snooze snoozeduntil a chosen time wakes up open again reply detected by the sweep or marked complete by a person done completion_source = reply | manual dismissed by a person dismissed never raised again — the row is the record that this mail was considered THE CHASE BUDGET first reminder: 2 h before due repeat: every 24 h stop after: 4 reminders escalate to admin: 48 h overdue budget spent → remind_at nulled. The task stays open on the list but stops shouting.
Figure 5 — Task lifecycle. A failed reminder send does not count against the budget: remind_at is left where it was so the next run tries again, rather than silently spending a chase on a reminder nobody received.

Screens

/follow-ups
The queue. Complete, snooze, dismiss and reopen act on one task each.
POST /follow-ups/enable
One click: write the settings row with shipped defaults, switch the feature on, and sweep immediately.
POST /follow-ups/scan
Runs detection on demand so the queue fills while the admin is still on the screen.
/follow-ups/settings
Due windows per next action, reminder lead time, repeat interval, maximum reminders, escalation threshold, email/in-app notification toggles, and an optional single notification address.
GET /follow-ups/notifications
Polled by the topbar bell on every page. Read-only — announcing a task in a browser must not spend the mailed reminder budget. Returns 204 for a signed-out tab so an overnight browser goes quiet instead of logging failures.

Reminders are sent as one digest per mailbox per run, never one mail per task — four separate reminders in a minute is what teaches somebody to filter them. The hourly cadence is deliberate: the per-task schedule is decided by remind_at, not by how often the command runs, so a faster tick would only produce more, smaller mails.

SECTION 7Cron and scheduling

7.1How the tick works

There is exactly one operating-system-level scheduled task, and it does not know what any of the jobs are. Windows Task Scheduler runs a wrapper every minute; the wrapper runs artisan schedule:run; Laravel decides what is due right now. One minute is correct and not wasteful — most ticks do nothing at all.

tools\run-scheduler-hidden.vbs   ← Task Scheduler runs this (no console flash)
  └─ tools\run-scheduler.bat
       └─ cd /d C:\xampp8_2\htdocs\EmailInsightAI
          C:\xampp8_2\php\php.exe artisan schedule:run >> storage\logs\scheduler.log 2>&1

The .vbs wrapper exists for one reason: without it a black console window flashes on screen every minute, all day. Task Scheduler can only hide that by running the task as another user, which needs a stored password — the wrapper avoids that. The 0 argument means hidden and the False means do not wait; the tick is fire-and-forget.

Where the schedule actually lives

app/Console/Kernel.php, not routes/console.php. The kernel is bound explicitly as a singleton at the bottom of bootstrap/app.php, which is what keeps the Laravel 10-style schedule() method working under Laravel 12. Editing routes/console.php to add a schedule will do nothing.

7.2The schedule

app/Console/Kernel.php
CommandCadenceOverlapDoes
inspireevery minute—Heartbeat only. Proves the tick is alive in scheduler.log.
app:process-email-queueevery 10 min—The mail sync. Takes 5 mailboxes from cron_queue, fetches, classifies, stores, assigns.
app:send-daily-email-summaryevery 5 min,
13:00–18:00
—The daily digest mail. The window is server local time; the intent is 18:00–22:00 CET.
app:process-auto-reply-queue
--batch=5 --limit=50 --days=2
every 10 minguardedSends acknowledgements for queued mailboxes, honouring priority.
app:retry-auto-replies
--all-clients
hourlyguardedRe-runs auto-replies that failed transiently. Re-runs the whole decision, so configuration changed since the failure is honoured.
app:process-follow-ups
--all-clients
every 15 minguardedRaises tasks for unanswered mail and closes ones since replied to. No AI call.
app:send-follow-up-reminders
--all-clients
hourlyguardedSends the reminder digests that are due.

“Guarded” is withoutOverlapping(): a run that is still going when the next is due is skipped rather than doubled. Every job also writes a before and after line to the Laravel log, so a stalled command is visible as a start with no matching finish.

ONE HOUR OF TICKS · minutes past the hour :00:10:20:30:40:50:60 schedule:run continuous — 60 ticks, most do nothing process-email-queue process-auto-reply-queue process-follow-ups send-daily-email-summary only between 13:00 and 18:00 retry-auto-replies send-follow-up-reminders Teal marks the two jobs that spend Gemini calls. At :00 six jobs fire together — the withoutOverlapping guard is what keeps that from compounding.
Figure 6 — Cadence over one hour. The top of every hour is the busiest moment in the system: sync, auto-reply, follow-up sweep, retries and reminders all land on the same tick. If throughput is a problem, staggering these is the first adjustment to make.

7.3Installing the scheduled tasks

Two Windows Task Scheduler entries are required on a fresh machine.

TaskRunsTriggerNotes
Laravel ticktools\run-scheduler-hidden.vbsevery 1 minute, indefinitelyRun whether the user is logged on or not. Point it at the .vbs, not the .bat, or a window flashes every minute.
Nightly backuptools\run-db-backup.batdaily at 00:01No hidden wrapper — it runs once, when nobody is watching.

7.4Logs

FileContains
storage/logs/scheduler.logRaw stdout of every schedule:run. First place to look when nothing is happening.
storage/logs/laravel.logThe before/after markers for each scheduled job.
storage/logs/auto_reply.logEvery auto-reply decision with its reason — a skipped reply can be explained without touching the database.
storage/logs/MailController_log_cron.txtErrors from the sync, including raw AI responses on a failure.
storage/logs/MailController_check_load_time_log_cron.txtStep-by-step timings through the sync. Use this to find which stage is slow.
storage/logs/worker.logSync entry markers.
storage/logs/Job_fail_log.logQueue rows that ended failed, with the exception message.
storage/logs/db-backup.logThe backup script's own readable history.
storage/logs/db-backup-run.logOnly catches a crash that happened before the script's logging started.

SECTION 8Command reference

All commands run from the project root. On this machine PHP is at C:\xampp8_2\php\php.exe.

8.1Scheduled commands

app:process-email-queue

The mail sync. Takes no options — batch size (5 mailboxes), page size and the 40-insert cap per mailbox are compiled in.

php artisan app:process-email-queue
Forward-only

This command builds its Gmail query as after:<newest stored sent_date> and falls back to “yesterday” when the table is empty. It can only ever look forward. If the messages table is emptied, this command re-syncs roughly one day and stops — no number of runs will go back for the rest. Use mail:backfill-gmail.

app:process-auto-reply-queue

OptionDefaultEffect
--batch=5Mailboxes taken from the queue in one run.
--limit=50Most messages evaluated per mailbox.
--days=2Only consider mail stored in the last N days.
--dry-runoffDecide everything, send nothing, keep nothing.

app:process-auto-replies — the sweep alternative

Same decisions, different mailbox selection: walks every active client rather than taking queue rows. Swap the schedule to this if priority ordering is not wanted.

OptionDefaultEffect
--all-clientsoffWalk every tenant in the master clients table.
--client=—One tenant, by client id.
--user=—One mailbox, by users.id.
--days=2Lookback window.
--limit=50Most messages per run.
--dry-runoffShow what would happen; send nothing, store nothing.
--mark-seenoffDraw a line under history. Records older mail as SKIPPED without calling the AI or sending.
First run on an established mailbox

The short lookback exists so a first run does not acknowledge months of old mail. Before widening --days, run with --mark-seen to record the history as considered. --dry-run and --mark-seen together are refused as meaningless.

app:retry-auto-replies

OptionDefaultEffect
--all-clientsoffWalk every tenant.
--client=—One tenant by client id.
--limit=25Maximum entries retried per tenant.
--max-attempts=3Leave an entry alone once tried this often.

app:process-follow-ups

Runs in two halves in a fixed order: raise first, then close. Closing first would be wasted on mail that has no task yet, and this order means a mail that arrived and was answered between two runs gets a task and loses it in the same pass.

OptionDefaultEffect
--all-clientsoffWalk every tenant.
--client=—One tenant by client id.
--owner=—One account, by the admin's users.id.
--days=7 (config)Lookback window.
--limit=200 (config)Most messages evaluated in one run.
--dry-runoffShow what would be raised; write nothing.
--no-closeoffSkip the answered-task pass.

app:send-follow-up-reminders

OptionDefaultEffect
--all-clientsoffWalk every tenant.
--client=—One tenant by client id.
--owner=—One account by admin id.
--limit=200Most tasks included in one run.
--dry-runoffShow what would be sent; send nothing, record nothing.

app:send-daily-email-summary

Sends the daily digest through PHPMailer. No options.

8.2Maintenance and recovery commands

mail:assign-existing

Applies the current category-to-team mapping to mail that is already stored and has no owner. Never touches mail that already has an owner — a rule-assigned mail because somebody may be working it, a manually assigned one because a person overruled the rule and that has to stand. The only way a mail comes back into scope is for somebody to unassign it.

OptionDefaultEffect
--user=allOnly this mailbox.
--account=allOnly mailboxes under this admin.
--dry-runoffReport what would change without writing.
--chunk=500Rows read per batch.

mail:backfill-gmail

Restores mail present in Gmail but missing from messages. Pages through the Gmail list API — which the live cron does not do — and inserts whatever is not already stored, oldest first, because the listings order by id DESC and therefore rely on a higher id meaning a newer mail.

OptionDefaultEffect
--email=requiredMailbox to impersonate.
--user=requiredmessages.user_id to attach restored mail to.
--after=—Gmail date syntax, e.g. 2025/01/01.
--before=—Gmail date syntax.
--limit=2000Stop after inserting this many.
--pages=50Maximum Gmail list pages to walk (500 per page).
--dry-runoffReport what would be restored.

AI columns are left null deliberately; next_action and the lead columns are filled from the same inferFrom() helpers the migrations used, so restored mail is honestly labelled inferred rather than pretending to be ai.

mail:relink-message-refs

Run this immediately after a backfill that followed a truncation. Truncating messages resets the auto-increment, so every stored numeric message_id elsewhere points at either nothing or — once ids climb back through the same range — a completely unrelated mail. The second case is the dangerous one: a follow-up would silently attach itself to somebody else's mail.

Repair is possible because three tables also store the provider's own id, which survives a truncation:

TableDurable keyMatches
follow_upsmessage_refmessages.email_id
auto_reply_logsmessage_refmessages.email_id
thread_summariesthread_keymessages.threadId
mail_summaries— none —not recoverable
php artisan mail:relink-message-refs --dry-run
php artisan mail:relink-message-refs --prune-summaries

mail_summaries cannot be re-linked — its summary_key is an opaque md5 of the set of mail summarised, so there is nothing to match on. It is a regenerable digest cache, so --prune-summaries drops the stale rows and lets the app rebuild them on demand.

8.3Useful ad-hoc invocations

# What would tonight's follow-up sweep raise? Change nothing.
php artisan app:process-follow-ups --owner=1 --dry-run

# Test one freshly approved template against one mailbox, safely.
php artisan app:process-auto-replies --user=7 --limit=5 --dry-run

# Silence the backlog before switching auto-reply on for a busy mailbox.
php artisan app:process-auto-replies --user=7 --days=90 --mark-seen

# Apply a routing change you just made on the Mail Routing screen.
php artisan mail:assign-existing --account=1 --dry-run
php artisan mail:assign-existing --account=1

# Run the whole schedule once, right now, without waiting for the tick.
php artisan schedule:run
Known tooling limitation

php artisan route:list does not work in this project — it fails before printing. To inspect routes, read routes/web.php directly; it is organised by feature with a comment block explaining each group's design.

SECTION 9Database and ER diagrams

Two databases. The master database is small, shared and operational; the tenant database is where an installation's real data lives, and every application table below is one of those.

Table inventory — tenant database
ClusterTables
Identity & orgusers · departments · inbox_groups · impmail · domain · user_otps · password_reset_tokens
Mail coremessages · sent_mails · user_emails_id_data · promotion_mails · freshcounts
AI vocabulariessentiments · next_actions · lead_types · email_categories · prompts
Auto replyemail_templates · auto_reply_settings · auto_reply_logs
Follow-upfollow_up_settings · follow_ups
Summariesthread_summaries · mail_summaries · project_responses
Frameworkmigrations · sessions · cache · cache_locks · jobs · job_batches · failed_jobs
Reading the diagrams

A solid line is a relationship the database enforces with a foreign key. A dashed line is a soft reference — the column holds an id but nothing stops it pointing at a deleted or foreign row. Most of this schema is soft, which is why several features do their own ownership check rather than trusting a join.

9.1Identity and organisation

Schema · cluster A — who exists and which team they are in

%%{init:{'theme':'base','themeVariables':{'primaryColor':'#ffffff','primaryTextColor':'#101617','primaryBorderColor':'#0d6e69','lineColor':'#55635f','tertiaryColor':'#f0f4f3','fontFamily':'IBM Plex Mono, monospace','fontSize':'12px'}}}%%
erDiagram
    users ||..o{ users : "auth_id = admin id"
    departments ||..o{ users : "group_by"
    users ||--o{ departments : "user_id owns the team"
    users ||--o{ user_otps : "login code"
    users ||--o{ domain : "own domain"
    users ||--o{ impmail : "important senders"
    inbox_groups ||..o{ impmail : "group_id"

    users {
        bigint id PK
        varchar email UK
        varchar password
        varchar login_password
        bigint auth_id "0 = account admin"
        varchar user_level "1 signs in, 2 mailbox only"
        varchar account_type "0 Gmail, 1 Microsoft"
        varchar is_department_head
        varchar group_by "departments.id"
        varchar is_service_account
        varchar app_email_pass "encrypted"
        varchar is_login "connection health"
        varchar fname
        varchar lname
        varchar contact
    }
    departments {
        bigint id PK
        bigint user_id FK "owning account"
        varchar department_name UK
        varchar department_lable UK
        varchar is_deletable
    }
    user_otps {
        bigint id PK
        bigint user_id FK
        varchar otp "6 digits"
        timestamp expires_at "5 minutes"
    }
    domain {
        bigint id PK
        bigint user_id FK
        varchar domain_name
    }
    inbox_groups {
        bigint id PK
        varchar group_name
        varchar auth_id
    }
    impmail {
        bigint id PK
        bigint user_id FK
        longtext sender_email
        longtext sender_name
        tinyint important_mail
        varchar group_id "inbox_groups.id"
    }
Cluster A. The self-reference on users is the whole account hierarchy: auth_id = 0 is an admin, any other value names their admin. group_by is dashed for a reason — see the hazard note in §4.1.

9.2Mail core

Schema · cluster B — the system of record

%%{init:{'theme':'base','themeVariables':{'primaryColor':'#ffffff','primaryTextColor':'#101617','primaryBorderColor':'#0d6e69','lineColor':'#55635f','tertiaryColor':'#f0f4f3','fontFamily':'IBM Plex Mono, monospace','fontSize':'12px'}}}%%
erDiagram
    users ||--o{ messages : "mailbox owns mail"
    users ||--o{ sent_mails : "outgoing"
    sent_mails ||..o{ messages : "sent_mail_id links reply to mail"
    users ||--o{ user_emails_id_data : "seen id cache"
    users ||--o{ promotion_mails : "promotion id cache"
    users ||..o{ freshcounts : "admin_id / emp_id"
    departments ||..o{ messages : "assigned_department_id"
    users ||..o{ messages : "assigned_user_id"

    messages {
        bigint id PK
        bigint user_id FK "the mailbox"
        longtext email_id "provider message id"
        varchar threadId "provider thread id"
        longtext message_id_uniqe "RFC Message-ID header"
        longtext sender_name
        longtext sender_email
        longtext subject
        longtext clean_subject "Re:/Fwd: stripped"
        varchar sent_date
        varchar sent_date_s
        longtext message "plain text"
        longtext message_with_html
        longtext message_with_regx "quoted history removed"
        varchar mimeType
        varchar attachment_status
        varchar label_ids
        varchar labelname
        varchar message_status "0 unread, 1 read"
        varchar admin_message_status
        varchar is_sent_mail
        varchar sent_mail_id "-1 = unreplied"
        varchar track_reply_email
        varchar root_email_id
        varchar is_promotion
        varchar is_spam "sentiment legacy_code"
        varchar is_spam_value "sentiment code"
        varchar action_required "1 = needs a reply"
        varchar is_action_required_value
        varchar category "category legacy_code"
        varchar is_category_value "category code"
        longtext gemini_return_json "raw AI response"
        longtext mail_summary
        varchar next_action
        varchar next_action_source "ai | inferred"
        tinyint is_potential_lead
        varchar lead_type
        tinyint lead_score "0-100"
        varchar lead_reason
        varchar lead_source "ai | inferred"
        bigint assigned_department_id
        bigint assigned_user_id
        varchar assignment_source "rule | manual"
        timestamp assigned_at
        bigint assigned_by
        longtext reply_draft
        timestamp reply_draft_generated_at
        varchar send_py
    }
    sent_mails {
        bigint id PK
        bigint user_id FK
        longtext sent_mail_id
        longtext subject
        longtext clean_subject
        varchar matched_subject
        longtext receiver_email
        longtext receiver_name
        longtext sent_htmlMessage
        varchar sent_date_s
    }
    user_emails_id_data {
        bigint id PK
        bigint user_id FK
        longtext email_ids
        tinyint is_sent_mail
    }
    promotion_mails {
        bigint id PK
        bigint user_id FK
        longtext email_ids
        bigint is_promotion_map_id
    }
    freshcounts {
        bigint id PK
        bigint admin_id
        bigint emp_id
        int current_count
        int previous_count
    }
Cluster B. messages is wide and deliberately denormalised: it carries the mail, every AI verdict, the assignment and the draft reply on one row, so a mail list renders from a single query with no joins. Every classification is stored twice — as a numeric legacy code and as a word — so a pre-vocabulary row and a post-vocabulary row both filter correctly.

9.3The AI vocabularies

Schema · cluster C — admin-editable label sets that feed the prompt

%%{init:{'theme':'base','themeVariables':{'primaryColor':'#ffffff','primaryTextColor':'#101617','primaryBorderColor':'#0d6e69','lineColor':'#55635f','tertiaryColor':'#f0f4f3','fontFamily':'IBM Plex Mono, monospace','fontSize':'12px'}}}%%
erDiagram
    users ||--o{ sentiments : "account vocabulary"
    users ||--o{ next_actions : "account vocabulary"
    users ||--o{ lead_types : "account vocabulary"
    users ||--o{ email_categories : "account vocabulary"
    users ||--o{ prompts : "custom prompt"
    sentiments ||..o{ messages : "is_spam_value = code"
    next_actions ||..o{ messages : "next_action = code"
    lead_types ||..o{ messages : "lead_type = code"
    email_categories ||..o{ messages : "is_category_value = code"
    departments ||..o{ email_categories : "assign_department_id = the routing table"

    sentiments {
        bigint id PK
        bigint user_id FK
        varchar code UK
        varchar name
        varchar description "goes into the prompt"
        varchar ai_aliases
        varchar filter_slug UK
        varchar colour
        int legacy_code UK "written to messages.is_spam"
        tinyint is_active
        int sort_order "broad first, specific last"
    }
    next_actions {
        bigint id PK
        bigint user_id FK
        varchar code UK
        varchar name
        varchar description
        varchar ai_aliases
        varchar filter_slug UK "action_ prefix"
        varchar colour
        tinyint is_active
        int sort_order "most urgent first"
    }
    lead_types {
        bigint id PK
        bigint user_id FK
        varchar code UK
        varchar name
        varchar description
        varchar ai_aliases
        varchar filter_slug UK "lead_ prefix"
        varchar colour
        tinyint min_score "score floor"
        varchar priority "high | medium | low"
        tinyint is_active
        int sort_order
    }
    email_categories {
        bigint id PK
        bigint user_id FK
        varchar code
        varchar name
        varchar description
        varchar ai_aliases
        int legacy_code
        varchar filter_slug "cat_ prefix"
        varchar colour
        tinyint is_lead_signal
        tinyint auto_reply_enabled
        bigint template_id FK
        bigint assign_department_id "the routing rule"
        decimal confidence_threshold "overrides account default"
        tinyint is_active
        int sort_order
    }
    prompts {
        bigint id PK
        bigint user_id FK
        text ai_prompt
        varchar keywords
        tinyint status
    }
Cluster C. Every dashed link from a vocabulary to messages is a string match on code, not an id join. That is what lets a vocabulary row be renamed, deactivated or re-banded without touching a single stored mail — and what makes ai_aliases load-bearing, since it is how yesterday's word still resolves to today's row.

9.4Auto reply

Schema · cluster D — configuration, and the record of every decision

%%{init:{'theme':'base','themeVariables':{'primaryColor':'#ffffff','primaryTextColor':'#101617','primaryBorderColor':'#0d6e69','lineColor':'#55635f','tertiaryColor':'#f0f4f3','fontFamily':'IBM Plex Mono, monospace','fontSize':'12px'}}}%%
erDiagram
    users ||--|| auto_reply_settings : "one row per account"
    users ||--o{ email_templates : "account owns"
    email_categories ||--o| email_templates : "template_id"
    email_templates ||--o{ auto_reply_logs : "template used"
    email_categories ||--o{ auto_reply_logs : "category decided"
    users ||--o{ auto_reply_logs : "mailbox"
    messages ||--o| auto_reply_logs : "one log row per mail, ever"

    auto_reply_settings {
        bigint id PK
        bigint user_id UK "the account"
        tinyint enabled
        decimal confidence_threshold "default 0.800"
        tinyint reply_once_per_thread
        tinyint manual_review_below_threshold
        tinyint reply_to_internal_senders
        int daily_limit_per_recipient "default 3"
        varchar company_name
        varchar default_product_name
        varchar signature_name
    }
    email_templates {
        bigint id PK
        bigint user_id FK
        varchar name
        bigint category_id FK
        varchar subject
        longtext body "double-brace placeholders"
        enum status "draft | approved | inactive"
        bigint approved_by
        timestamp approved_at
    }
    auto_reply_logs {
        bigint id PK
        bigint user_id FK "mailbox"
        bigint owner_id "account"
        bigint message_id "numeric, resettable"
        varchar message_ref "provider id, durable"
        varchar thread_id
        varchar internet_message_id
        bigint category_id FK
        varchar category_code
        decimal ai_confidence
        longtext ai_raw_response
        bigint template_id FK
        varchar recipient_email
        varchar recipient_name
        varchar subject
        longtext body "exactly what was sent"
        enum status "PENDING SENT FAILED SKIPPED MANUAL_REVIEW ALREADY_SENT"
        timestamp sent_at
        varchar provider_message_id
        text error_reason
        int attempts
    }
Cluster D. The one-to-zero-or-one between messages and auto_reply_logs is the idempotency guarantee: the existence of a log row means this mail has been considered, so the presence check is also the lock. Note that message_ref sits beside message_id — that redundancy is what made recovery possible after the 2026-09-07 truncation.

9.5Follow-ups and summaries

Schema · cluster E — the chase queue and the generated summaries

%%{init:{'theme':'base','themeVariables':{'primaryColor':'#ffffff','primaryTextColor':'#101617','primaryBorderColor':'#0d6e69','lineColor':'#55635f','tertiaryColor':'#f0f4f3','fontFamily':'IBM Plex Mono, monospace','fontSize':'12px'}}}%%
erDiagram
    users ||--|| follow_up_settings : "one row per account"
    users ||--o{ follow_ups : "mailbox"
    messages ||--o| follow_ups : "one task per mail, ever"
    next_actions ||..o{ follow_ups : "trigger and due window"
    users ||--o{ thread_summaries : "per conversation"
    users ||--o{ mail_summaries : "per digest key"
    messages ||..o{ thread_summaries : "thread_key = threadId"

    follow_up_settings {
        bigint id PK
        bigint user_id UK
        tinyint enabled "opt-in"
        int default_due_hours "48"
        longtext due_windows "per next_action override"
        int reminder_lead_hours "2"
        int repeat_every_hours "24"
        int max_reminders "4"
        int escalate_after_hours "48"
        tinyint email_reminders
        tinyint notify_in_app
        varchar notify_email "one desk instead of the owner"
    }
    follow_ups {
        bigint id PK
        bigint user_id FK "mailbox"
        bigint owner_id "account"
        bigint message_id
        varchar message_ref "durable provider id"
        varchar thread_key
        varchar sender_email
        varchar sender_name
        varchar subject
        timestamp mail_sent_at
        varchar trigger_type "next_action | action_required | manual"
        varchar next_action
        varchar sentiment
        varchar reason
        varchar priority "high | normal | low"
        timestamp due_at
        timestamp remind_at "nulled when budget spent"
        timestamp snoozed_until
        varchar status "open | done | dismissed"
        varchar completion_source "reply | manual"
        timestamp completed_at
        bigint completed_by
        int reminder_count
        timestamp last_reminded_at
        tinyint escalated
        text note
    }
    thread_summaries {
        bigint id PK
        bigint user_id FK
        varchar thread_key "provider thread id"
        longtext summary_json "validated, five sections"
        int message_count "staleness check"
        bigint last_message_id "staleness check"
        varchar model
        timestamp generated_at
    }
    mail_summaries {
        bigint id PK
        bigint user_id FK
        varchar summary_key "md5 of the mail set"
        longtext summary_html
        int message_count
        bigint last_message_id
    }
Cluster E. thread_summaries stores both message_count and last_message_id so staleness can be detected two ways — a reply arriving, or the thread growing — and the screen offers a rebuild instead of quietly showing yesterday's answer as today's.

9.6The master database

Schema · cluster F — the tenant directory and the work queue

%%{init:{'theme':'base','themeVariables':{'primaryColor':'#ffffff','primaryTextColor':'#101617','primaryBorderColor':'#0d6e69','lineColor':'#55635f','tertiaryColor':'#f0f4f3','fontFamily':'IBM Plex Mono, monospace','fontSize':'12px'}}}%%
erDiagram
    clients ||--o{ cron_queue : "one row per mailbox to sync"
    clients ||--o{ cron_mail_send : "digest dispatch queue"
    clients ||..o| outlook_creds : "tenantId"

    clients {
        int id PK
        varchar name
        varchar db_name "the tenant database"
        varchar company_name
        varchar domain_name
        varchar company_code
        varchar company_mail
        varchar mail_type
        varchar tenantId "Microsoft tenant"
        tinyint priority
        enum status "active | inactive"
        datetime expired_at
    }
    cron_queue {
        int id PK
        int client_id FK
        int employee_id "users.id in the tenant DB"
        enum status "pending processing done failed"
        tinyint priority "higher runs first"
        int progress_count
        timestamp created_at "today only"
    }
    cron_mail_send {
        int id PK
        int client_id FK
        enum mail_send "done pending processing failed"
        tinyint priority
    }
    outlook_creds {
        int id PK
        varchar tenantId
        varchar clientId
        varchar clientSecret
    }
    portal_users {
        int id PK
        varchar first_name
        varchar last_name
        varchar email
        varchar password
        enum Status "Active | inactive"
    }
Cluster F. cron_queue.employee_id is the crossing point between the two databases: it is a users.id in the tenant named by clients.db_name, and nothing in either database enforces that. The master also carries its own users table (shown here as portal_users to avoid confusion with the tenant's) and a staging messages table.

SECTION 10Table dictionary

The ER diagrams above list the columns. This section says what each table is for, and what will surprise you about it.

10.1Identity and organisation

users
Both people and mailboxes. An admin, a department head and a mail-only account are the same table distinguished by auth_id, user_level and is_department_head. Two near-duplicate columns exist and both are live: contact and the misspelt conatct; the Settings form writes contact. Likewise password and login_password are both hashes written at registration.
departments
Teams. user_id is the owning account, which is the only reliable way to tell whose team a row is. department_name and department_lable are both unique across the whole table, so two accounts cannot both have a team called “Sales”.
user_otps
One row per user, upserted on each login attempt. Five-minute expiry. Not cleaned up — an old row is simply overwritten.
password_reset_tokens
Standard Laravel reset tokens.
domain
The account's own mail domain, defaulting to @gmail.com. Read when deciding whether a sender is internal, which auto-reply skips unless told otherwise.
inbox_groups
Named lists of important senders. auth_id is a varchar defaulting to -1 and is not a foreign key.
impmail
Individual important senders, optionally in a group via the varchar group_id. A group's mail listing is filter slug search-{group_id}.

10.2Mail core

messages
One row per mail, and the system of record for everything. Fifty-odd columns because a mail list must render without joins. Read the notes below before writing any query against it.
sent_mails
Outgoing mail, matched back to the incoming mail it answers. When a match is found, messages.sent_mail_id stops being -1 and the mail counts as replied.
user_emails_id_data
A cache of provider ids already seen, so the sync can decide what is new without querying messages for every id.
promotion_mails
The same idea for mail classified as promotional.
freshcounts
Per-admin, per-employee message counts, used to show “N new since you last looked”. admin_id and emp_id are plain integers with no constraints.

What will surprise you about messages

ColumnThe catch
is_spamNot a spam flag. It holds the sentiment's numeric legacy_code. Provider spam is detected from label_ids instead.
is_spam_valueThe sentiment's word. This is the one the filters actually match on.
category / is_category_valueSame pairing: number then word. Legacy numbers are 1 Sales, 2 AMC, 3 External, 101 Unknown.
sent_mail_idDefault is the string '-1', meaning unreplied — not null, not zero.
email_idThe provider's message id, and the durable identity of a mail. Unlike id it survives a truncation.
next_action_source / lead_sourceai when the model answered; inferred when derived from older columns by a migration or by the backfill command. Never conflate the two when measuring model quality.
mail_summaryNull means not generated yet, not no summary. The app fills it lazily on view.
assigned_department_idNo foreign key. A deleted department leaves orphaned assignments.
gemini_return_jsonThe raw model response, kept for debugging a misclassification.
reply_draftAn AI draft awaiting a human. Its presence never causes a send.

10.3Vocabularies, auto reply, follow-ups

sentiments · next_actions · lead_types
Account vocabularies with a nullable user_id — a null row is a global default available to every account. code and filter_slug are unique table-wide, so two accounts cannot define the same code.
email_categories
The busiest configuration table in the system: the classifier's vocabulary, the auto-reply switch, the template link, the confidence override and the department routing rule all live on this one row. user_id is required here, unlike the other three.
prompts
Per-account custom prompt text and priority keywords from the Settings screen.
email_templates
Subject and HTML body. Only status = 'approved' is ever sent; approved_by and approved_at record who signed it off.
auto_reply_settings
Exactly one row per account, enforced by a unique key on user_id. A missing row means the account never opted in, and the config/auto_reply.php defaults apply.
auto_reply_logs
Every decision, including the ones that sent nothing. body holds exactly what was delivered, so a customer complaint about an automated reply can be answered from the database.
follow_up_settings
One row per account. due_windows is JSON keyed by next_actions.code, overriding config/follow_up.php per action.
follow_ups
One task per message, unique-keyed. A dismissed row is as final as a done one — both stop the mail ever being raised again.

10.4Generated content

thread_summaries
Validated JSON, one row per conversation per mailbox. Regenerable at any time; deleting a row costs one Gemini call to rebuild.
mail_summaries
Period-digest cache keyed by an opaque md5 of the mail set. Not repairable after a truncation — prune and let it rebuild.
project_responses
A general AI request/response log: project name, input, response.

10.5Framework tables

migrations, sessions, cache, cache_locks, jobs, job_batches and failed_jobs are standard Laravel. Note that the application does not use the Laravel queue for its own work — jobs is empty in normal operation, because the scheduling model is the master cron_queue table instead.

You may also find messages_status_backup_20260901: a one-off snapshot taken before a bulk status change. It is not read by any code and is safe to drop once no longer needed.

SECTION 11Configuration reference

The division is deliberate and worth stating plainly: infrastructure lives in files, business rules live in the database. Credentials, endpoints and hard safety limits are in .env and config/*.php. Which category replies, with which template, at what confidence, chased for how long — all of that is admin-editable data, because the business rule is that configuration decides, not the AI.

11.1Environment variables

.env
KeyExampleNotes
APP_URLhttp://localhostBase URL used in mailed links.
DB_CONNECTIONmysqlDefault connection. The framework default is sqlite — this must be set.
DB_HOST / DB_PORTlocalhost / 3306Also used by the clientdb connection.
DB_DATABASEEmailInsightThe tenant database the web app serves.
DB_USERNAME / DB_PASSWORD—Shared by both tenant connections.
MASTER_DB_HOST127.0.0.1Master database host.
MASTER_DB_DATABASEEmailInsight_masterFalls back to master_db if unset — which will silently fail.
MASTER_DB_USERNAME / _PASSWORD—Master credentials.
GEMINI_API_KEY—One key for classification, thread summaries, reply drafts and auto-reply.
MAIL_MAILER / MAIL_HOST / MAIL_PORTsmtp · smtp.gmail.com · 587Outbound SMTP for OTPs, reminders and digests.
MAIL_USERNAME / MAIL_PASSWORD—SMTP credentials.
MAIL_ENCRYPTIONtls—
MAIL_FROM_ADDRESS / _NAME—Sender identity on system mail.

Feature switches

KeyDefaultEffect
AUTO_REPLY_ENABLEDtrueMaster kill switch. When false the pipeline never sends, whatever any account's settings say.
AUTO_REPLY_CONFIDENCE0.80Fallback threshold for an account with no settings row.
AUTO_REPLY_DAILY_LIMIT3Fallback per-recipient daily cap.
AUTO_REPLY_GEMINI_URL / _TIMEOUTflash · 30 sClassifier endpoint and timeout.
GMAIL_SERVICE_ACCOUNT_FILEstorage/app/email/service-account.jsonDomain-wide delegation key.
OUTLOOK_TENANT_ID / _CLIENT_ID / _CLIENT_SECRET—Graph client-credentials. Per-tenant values may instead come from master.outlook_creds.
OUTLOOK_TOKEN_FILEstorage/app/email/outlook_account.jsonGraph token cache.
FOLLOW_UP_ENABLEDtrueMaster kill switch. No task created, no reminder sent.
FOLLOW_UP_DEFAULT_HOURS48Window for an action with none of its own.
FOLLOW_UP_LEAD_HOURS2How long before due the first reminder goes out.
FOLLOW_UP_REPEAT_HOURS24Repeat interval while overdue.
FOLLOW_UP_MAX_REMINDERS4Hard stop on chasing.
FOLLOW_UP_ESCALATE_HOURS48Overdue by this much and the admin is copied in. 0 disables.
FOLLOW_UP_LOOKBACK_DAYS7Sweep candidate window.
FOLLOW_UP_BATCH_LIMIT200Messages evaluated per pass.
FOLLOW_UP_MAX_OPEN300Open tasks one mailbox may hold. At the cap the sweep stops creating rather than burying the real queue.
Staging copies

The two master kill switches exist specifically for staging databases restored from production, which share the real mailboxes' addresses. Set AUTO_REPLY_ENABLED=false and FOLLOW_UP_ENABLED=false in any non-production .env before the first scheduler tick, or the copy will mail real customers.

11.2Config files worth reading

config/auto_reply.php
Kill switch, fallback thresholds, the blocked-local-part list, Gemini endpoint, Gmail scope, Graph credentials, log file path.
config/follow_up.php
Kill switch, defaults, the due_windows table keyed by next-action code, the urgent_sentiments list that overrides priority, and the sweep safety limits.
config/thread_summary.php
Primary and fallback Gemini endpoints for the conversation summariser.
config/database.php
The three connections: mysql, master, and clientdb with its deliberately empty database name.

SECTION 12Backup and recovery

Read this before touching data

Binary logging is off on this server. There is no point-in-time recovery. The nightly dump is the only safety net, and it has existed only since 8 September 2026 — it was written in response to the messages table being truncated by mistake on 7 September 2026, when nothing could bring it back. The dated project zips beside the project root are code only; they contain no data. This project is also not under version control, so those zips are likewise the only way to recover a lost or truncated source file.

12.1The nightly dump

tools\backup-databases.ps1, launched by tools\run-db-backup.bat at 00:01. Per run it:

  1. Reads database credentials from the project .env — a single source of truth, so a password change does not silently break backups.
  2. Asks the server for its database list, minus the virtual ones (information_schema, performance_schema, sys) which cannot be restored.
  3. mysqldumps each database into its own self-contained .sql.
  4. Verifies each dump finished, by checking for mysqldump's “Dump completed” trailer — a truncated dump would otherwise look like a perfectly fine file.
  5. Zips the night into one archive and deletes the loose .sql files.
  6. Deletes archives older than the retention window.
ParameterDefaultNotes
-BackupRootC:\xampp8_2\db-backupsMust stay outside htdocs. Anything under htdocs is served by Apache, and a world-readable dump of every database is worse than no dump at all.
-RetentionDays14About a few hundred MB of history.
-MysqlBinC:\xampp8_2\mysql\bin—
-ProjectRootC:\xampp8_2\htdocs\EmailInsightAIWhere the .env is read from.

12.2Restoring

# 1. Take a fresh dump first — a bad restore over live data is unrecoverable.
powershell -NoProfile -ExecutionPolicy Bypass -File tools\backup-databases.ps1

# 2. Unzip the chosen night from C:\xampp8_2\db-backups

# 3. Restore one database
C:\xampp8_2\mysql\bin\mysql.exe -u root -p EmailInsight < EmailInsight.sql

12.3Recovering from a lost messages table

This is the specific disaster the tooling was built for, and the order of operations matters.

  1. Do not wait for the cron. It only looks forward, so it will re-sync roughly one day and stop. Everything older stays missing permanently.
  2. Restore from the most recent nightly dump if one covers the loss.
  3. For anything the dump does not cover, page it back out of the provider:
    php artisan mail:backfill-gmail --email=ami@mukesoft.com --user=1 \
      --after=2025/01/01 --before=2026/09/07 --dry-run
    then re-run without --dry-run. Mail is inserted oldest-first so the id DESC listings stay in the right order.
  4. Immediately repair the references, because ids have been reassigned:
    php artisan mail:relink-message-refs --dry-run
    php artisan mail:relink-message-refs --prune-summaries
  5. Re-apply routing to the restored mail once something has classified it:
    php artisan mail:assign-existing --dry-run
Why step 4 cannot be skipped

A truncation resets the auto-increment. Once ids climb back through the same range, an unrepaired follow_ups.message_id points at a completely unrelated mail — and a follow-up task silently attached to somebody else's mail is worse than a missing one.

SECTION 13Troubleshooting

SymptomCheck, in this order
No new mail is appearing1. Is scheduler.log growing? If not, the Task Scheduler entry is not firing. 2. Is there a cron_queue row for that mailbox created today? The query filters on CURDATE(). 3. Is the row stuck at failed? See Job_fail_log.log. 4. Run the Settings connection test.
Mail stopped at a certain date and never resumedThe sync is forward-only. If the table was emptied or the newest stored sent_date is wrong, the query window is wrong. Use mail:backfill-gmail.
Mail arrives but has no labelsThe Gemini call failed. Check MailController_log_cron.txt for 429, 503 or 250. A 429 means the daily or per-minute quota is spent; nothing will classify until it resets.
Auto-reply sends nothingWalk the funnel in §6.5 against auto_reply_logs — the status column names the gate that stopped it. The usual causes are a template still in draft, the category's auto_reply_enabled off, or confidence below threshold producing MANUAL_REVIEW.
Auto-reply answered the wrong thingOpen the log entry: it carries the raw AI response and the confidence. Fix the category's description — that text is the prompt — or raise that category's own confidence_threshold.
Nothing is assigned to any teamAssignment happens at classification time. Existing mail needs mail:assign-existing. Also confirm each department has exactly one member with is_department_head = 1; with no head there is nobody to name as owner.
“Assigned to me” is empty for a department headConfirm users.group_by names a department whose user_id is this account. A group_by pointing at a foreign account's department resolves to nothing.
No follow-up tasksThe feature is opt-in per account. Check follow_up_settings.enabled, or press Enable on the follow-ups screen, which writes the row and sweeps immediately. Then check FOLLOW_UP_ENABLED in .env.
Follow-up reminders stopped for one taskExpected once reminder_count hits max_reminders: remind_at is nulled and the task stays open but stops chasing.
A thread summary looks out of dateIt is cached and marked stale when the conversation grows. Use the regenerate control rather than deleting the row.
A filter slug shows the wrong listResolution order is sentiment → next action → lead → category → assignment → fixed. A custom vocabulary row whose slug collides with an earlier vocabulary will be shadowed. Re-slug it with its own prefix.
Six jobs fire at once every hourExpected at :00. If throughput suffers, stagger the hourly jobs in Kernel.php. The withoutOverlapping() guard prevents compounding but not contention.
php artisan route:list failsKnown and unfixed. Read routes/web.php directly.
Operational rule

Never pipe the output of a regex or a search back over a source file in this project. A one-line in-place edit of that shape truncated MailController.php to zero bytes, and with no version control the only recovery was a dated zip. Edit files with an editor; keep a copy first.

SECTION 14Glossary

Account
An admin user (auth_id = 0) and every mailbox beneath them. Configuration belongs here.
Mailbox
One row in users that mail is synced into. Mail belongs here.
Tenant
One client company, with its own database, listed in master.clients.
Vocabulary
An admin-editable label set injected into the Gemini prompt: sentiments, next actions, lead types, email categories.
Filter slug
The {type} segment of /mails/{type}. Prefixed per vocabulary so the five cannot collide.
Legacy code
The numeric value a label had before vocabularies existed, kept so pre-vocabulary mail still filters.
AI alias
An older word that maps to a current vocabulary row. What makes a vocabulary change free.
Lead score / floor
The model's 0–100 confidence, and the per-type min_score below which it is not counted.
Priority band
High, Medium or Low, computed at read time from lead_types.priority — never stored on the mail.
Manual review
An auto-reply the system declined to send because confidence was below threshold, queued for a human.
Chase budget
The finite number of reminders a follow-up task may generate before it stops chasing but stays open.
Forward-only sync
The mail fetch only asks the provider for mail newer than the newest already stored. The single most important operational fact in this manual.
Durable reference
A provider-issued id (email_id, threadId) that survives a table truncation, unlike a local auto-increment.

EmailInsightAI Operator Manual · Revision 2026.09 · Compiled 8 September 2026 from the live codebase and database schema at C:\xampp8_2\htdocs\EmailInsightAI.

Feature-level design notes live alongside the code in docs/: auto-reply.md, auto-assignment.md, follow-up-reminder.md, email-summary.md, lead-priority.md, next-action.md. Where this manual and the code disagree, the code is right — most classes carry a docblock explaining not just what they do but why they were built that way.