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.
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 | user_level | auth_id | Can do |
|---|---|---|---|
| Account admin | 1 | 0 | Everything. Owns the configuration — categories, templates, vocabularies, routing, follow-up settings. Sees every mailbox under the account and the full sidebar. |
| Department head | 1 | admin's id | Signs in, works their own mailbox plus mail routed to their department. Carries is_department_head = 1 and a group_by naming their department. |
| Employee mailbox | 2 | admin's id | A 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.
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
bootstrap/app.php, so the classic app/Console/Kernel.php schedule still applies rather than routes/console.php..xlsx.gemini-pro-latest after repeated 503 responses.1.4System architecture
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.
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.
| Field | Written to | Meaning |
|---|---|---|
| action_required | action_required | send-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. |
| sentiment | is_spam, is_spam_value | A code from the account's sentiments table. The numeric legacy_code goes in is_spam; the word goes in is_spam_value. |
| category | category, is_category_value | A code from email_categories. Drives auto-reply eligibility and department routing. |
| next_action | next_action, next_action_source | The single most useful thing the recipient should do next. Source is ai when the model answered, inferred when derived from older columns. |
| is_potential_lead | is_potential_lead | True only when the sender might buy — a vendor selling to you is explicitly not a lead. |
| lead_type | lead_type, lead_source | A code from lead_types, which also carries the priority band and the score floor. |
| lead_score | lead_score | 0–100 confidence that this is a real buying opportunity. Below the type's min_score the mail is not counted as a lead. |
| lead_reason | lead_reason | One sentence quoting what in the mail makes it a lead. |
| summary | mail_summary | 1–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
| Code | Meaning | What the pipeline does |
|---|---|---|
| 503 | Model overloaded | Sleeps 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. |
| 429 | Rate limit | Aborts 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. |
| 250 | Predict API unreachable | Aborts and logs to MailController_log_cron.txt. |
| — | Empty or non-JSON reply | Aborts before writing. A malformed classification is never stored as if it were real. |
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_DB_DATABASE. Holds clients, cron_queue, cron_mail_send, outlook_creds, plus a portal users table and a staging messages table.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.clientdb would point at.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
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.
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
- Create the tenant database and run the migrations against it.
- Insert a row into
master.clientswithname,db_name,company_name,domain_name,mail_type,status = 'active'and, for Microsoft mail, thetenantId. - For Microsoft mail, add the matching
master.outlook_credsrow (tenantId,clientId,clientSecret). - Register the admin user in the tenant database with
auth_id = 0anduser_level = 1. - Add employee mailboxes — individually via Add Email, or in bulk via the Excel upload.
- Insert one
master.cron_queuerow per mailbox that should be synced.
SECTION 4Accounts, access and mailbox connection
4.1The columns that define a user
| Column | Meaning |
|---|---|
| auth_id | 0 marks the account admin. Any other value is the admin's id, making this row one of their mailboxes. |
| user_level | 1 may sign in (admin or department head). 2 is a synced mailbox. 0 is explicitly refused at login. |
| account_type | 0 = Google Workspace (Gmail API). 1 = Microsoft 365 (Graph). This single column selects the entire fetch and send path. |
| is_department_head | 1 for the one member of a department who owns routed mail. Exactly one per department. |
| group_by | The departments.id this user belongs to, or 0. |
| is_service_account | 1 when the mailbox is reached through domain-wide delegation rather than a stored app password. |
| app_email_pass | Laravel-encrypted app password. Only used on the legacy IMAP path; service-account mailboxes never need it. |
| is_login | Connection-health flag, set by the Settings connection test: 0 healthy, 1 failing. |
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.
POST /loginvalidates the credentials withAuth::validate()— note that this does not start a session.- A user with
user_level = 0is refused here. - A six-digit OTP is written to
user_otpswith a five-minute expiry (one row per user, upserted) and mailed to the address. - The browser is redirected to
/otp-verify/{user}. POST /otp-verifychecks the code and expiry, then logs the session in.POST /resend-otpissues a fresh code.
Password recovery
password_reset_tokens.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.
| Scope | Used for |
|---|---|
| gmail.readonly | Fetching mail in the sync pipeline. |
| gmail.modify | Sending 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.
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.
| Entry | Route | Purpose |
|---|---|---|
| Dashboard | /dashboard | Volume, 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) | /employee | Every mailbox under the account, with per-mailbox counts. |
| Departments (admin) | /departments | Create, rename and delete teams. |
| Assign Department | /eassign-department | Put people into teams and name each team's head. |
| Department List | /admin-group/{id?} | Heads and their members. |
| Email Settings | /set_email | Important-sender lists and inbox groups. |
| Add Email (admin) | /add-email | Register one more mailbox. |
| Auto Reply | /auto-reply | Settings, categories, templates and the decision log. |
| Sentiments | /sentiments | The mood vocabulary the classifier may return. |
| Next Actions | /next-actions | The action vocabulary, and the follow-up windows keyed to it. |
| Lead Types | /lead-types | Buying signals, score floors and priority bands. |
| Mail Routing | /mail-routing | Which team owns which category. |
| Follow-ups | /follow-ups | The queue of mail owed an answer. Carries the overdue badge. |
| Domains | /domain | The account's own domain, used to tell internal mail from external. |
| Settings | /settings | Profile, 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.
email_categories, including legacy-coded mail via ai_aliases.lead_types.priority and each type's min_score floor.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:
- Sentiments — claimed the parameter first historically, so tried first.
- Next actions — prefix
action_. - Lead types — prefix
lead_, plus the three priority bands andleads. - Email categories — prefix
cat_. - 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.
| Slug | Group | Shows |
|---|---|---|
| all | Fixed | Every mail in the mailbox. |
| unread / read | Fixed | message_status 0 or 1. |
| send-mail | Fixed | Reply pending: action_required = 1, not spam-labelled, not promotion, not yet replied, still unread. |
| replied_mail | Fixed | Mail an outgoing message has been matched to. |
| promotion | Fixed | is_promotion != 0, unread. |
| search-{groupId} | Fixed | Mail from every sender in that inbox group. |
| sales / amc | Legacy | The original two-bucket split, kept working through legacy_code. |
| is_not_spam · neutral · is_spam | Sentiment | Positive, Neutral, Negative. The odd names are the original column values, preserved so old bookmarks survive. |
| sentiment_angry · sentiment_complaint · … | Sentiment | Every additional mood an admin has defined. |
| action_respond_immediately | Next action | Sender is waiting right now. |
| action_escalate_manager · action_escalate_technical | Next action | Needs a decision, or needs an engineer. |
| action_call_customer · action_send_quotation · action_schedule_demo · action_follow_up | Next action | The remaining standard actions. |
| leads | Lead | Everything scoring above its type's floor. |
| lead_priority_high / _medium / _low | Lead | The 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_enquiry | Lead | One 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_enquiry | Category | One per shipped category. |
| assigned_me | Assignment | The one listing that crosses mailboxes. Mail routed to you, wherever it landed. |
| assigned_any | Assignment | Owned by somebody, any team. |
| assigned_none | Assignment | Classified, routable, and nobody has it. The queue worth watching. |
| assigned_dept_{id} | Assignment | One team's queue. Keyed by id, not name, so a rename never breaks a bookmark. |
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
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:
| Feature | Stored in | What it is |
|---|---|---|
| Per-mail summary | messages.mail_summary | The one-or-two sentence summary field from the classification call. Generated with the mail, or lazily on first view for backfilled mail. |
| Thread summary | thread_summaries | A whole conversation reduced to five sections plus a status. Generated on demand, cached per conversation, and marked stale when a new reply arrives. |
| Period summary | mail_summaries | A 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
user_level: level 2 sees only their own auth_id tree, level 0 sees all.department_name, a display department_lable, and an is_deletable guard on the built-in ones./inboxgroup/view-head/{id} drills into one head.search-{id} on the mail toolbar.impmail) and assigns them to groups.5.6Bulk import and export
.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..xlsx.5.7Settings
prompts.users.is_login.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.
| Column | Role |
|---|---|
| code | What the model returns and what is stored on the message. |
| name | What a person sees on the chip. |
| description | Injected 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_aliases | Comma-separated older words that map to this row. This is what makes a vocabulary change free: mail classified last year still resolves. |
| filter_slug | The URL segment. Prefixed per vocabulary so the five cannot collide. |
| colour | One of slate, green, red, amber, blue, violet. |
| is_active | Inactive rows leave the prompt but existing mail keeps its label. |
| sort_order | Order in the prompt — which matters, see below. |
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:
| Code | Name | Chosen when | Follow-up window |
|---|---|---|---|
| respond_immediately | Respond Immediately | An outage, a blocked operation, an expiring deadline, or explicit words like ASAP. | 4 h |
| escalate_manager | Escalate to Manager | Threatens to leave, demands compensation, disputes a commitment. Somebody with authority must decide, not merely answer. | 8 h |
| escalate_technical | Escalate to Technical | A fault, bug or performance problem needing a diagnosis. | 24 h |
| call_customer | Call the Customer | Asks to be phoned, or is too tangled to settle over mail. | 24 h |
| send_quotation | Send Quotation | The reply the sender wants is a price. | 48 h |
| schedule_demo | Schedule a Demo | The next step is an appointment. | 48 h |
| follow_up | Follow Up | Needs chasing later rather than answering now. | 72 h |
| no_action | No Action | Nothing 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.
| Code | Name | Floor | Band | Reasoning |
|---|---|---|---|---|
| purchase_requirement | Purchase Requirement | 40 | High | Already decided to buy; working out from whom. |
| quotation_request | Quotation Request | 45 | High | Wants a document to act on. |
| pricing_request | Pricing Request | 50 | High | Still weighing cost. |
| demo_request | Demo Request | 50 | High | The next step is showing, not telling. |
| meeting_request | Meeting Request | 55 | Medium | Wants time in the diary, but has asked for nothing a competitor could answer first. |
| renewal_upsell | Renewal or Upsell | 50 | High | Renewals lapse on a date — a missed one is revenue gone, not deferred. |
| product_enquiry | Product Enquiry | — | Low | Interest 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.
| Code | Name | Lead signal | Covers |
|---|---|---|---|
| NEW_LEAD | New Lead | yes | First approach from someone not yet a customer. |
| PRICING_ENQUIRY | Pricing Enquiry | yes | Price, quotation, rate card, discount, commercial terms. |
| DEMO_REQUEST | Demo Request | yes | Demo, walkthrough, trial, presentation. |
| PRODUCT_ENQUIRY | Product Enquiry | yes | What a product does; details, brochures, availability. |
| COMPLAINT | Complaint | no | Dissatisfaction or an escalated unresolved issue. |
| SUPPORT_REQUEST | Support Request | no | Problem with something already bought, including all AMC work — renewals, service schedules, warranty, SLA. |
| PAYMENT_BILLING | Payment or Billing | no | Invoices, reminders, receipts, POs, tax documents. |
| EXISTING_CUSTOMER | Existing Customer | no | Routine correspondence where nothing new is being bought. |
| VENDOR | Vendor | no | Somebody selling to this business, or an existing supplier. |
| SPAM | Spam | no | Unsolicited bulk, phishing, mass marketing. |
| GENERAL_ENQUIRY | General Enquiry | no | The 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.
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
{{variable}} placeholders. Status is draft → approved → inactive; only approved templates are ever sent. Preview renders with sample values.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.
| Placeholder | Filled 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.
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 messages | Holds |
|---|---|
| assigned_department_id | The team the category routed to. |
| assigned_user_id | The department head, or the person a manual override named. |
| assignment_source | rule or manual. A manual assignment always outranks the rule. |
| assigned_at | When ownership was established. |
| assigned_by | Who 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.
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_actionout of the box).
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
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.
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
| Command | Cadence | Overlap | Does |
|---|---|---|---|
| inspire | every minute | — | Heartbeat only. Proves the tick is alive in scheduler.log. |
| app:process-email-queue | every 10 min | — | The mail sync. Takes 5 mailboxes from cron_queue, fetches, classifies, stores, assigns. |
| app:send-daily-email-summary | every 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 min | guarded | Sends acknowledgements for queued mailboxes, honouring priority. |
| app:retry-auto-replies --all-clients | hourly | guarded | Re-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 min | guarded | Raises tasks for unanswered mail and closes ones since replied to. No AI call. |
| app:send-follow-up-reminders --all-clients | hourly | guarded | Sends 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.
7.3Installing the scheduled tasks
Two Windows Task Scheduler entries are required on a fresh machine.
| Task | Runs | Trigger | Notes |
|---|---|---|---|
| Laravel tick | tools\run-scheduler-hidden.vbs | every 1 minute, indefinitely | Run whether the user is logged on or not. Point it at the .vbs, not the .bat, or a window flashes every minute. |
| Nightly backup | tools\run-db-backup.bat | daily at 00:01 | No hidden wrapper — it runs once, when nobody is watching. |
7.4Logs
| File | Contains |
|---|---|
| storage/logs/scheduler.log | Raw stdout of every schedule:run. First place to look when nothing is happening. |
| storage/logs/laravel.log | The before/after markers for each scheduled job. |
| storage/logs/auto_reply.log | Every auto-reply decision with its reason — a skipped reply can be explained without touching the database. |
| storage/logs/MailController_log_cron.txt | Errors from the sync, including raw AI responses on a failure. |
| storage/logs/MailController_check_load_time_log_cron.txt | Step-by-step timings through the sync. Use this to find which stage is slow. |
| storage/logs/worker.log | Sync entry markers. |
| storage/logs/Job_fail_log.log | Queue rows that ended failed, with the exception message. |
| storage/logs/db-backup.log | The backup script's own readable history. |
| storage/logs/db-backup-run.log | Only 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
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
| Option | Default | Effect |
|---|---|---|
| --batch= | 5 | Mailboxes taken from the queue in one run. |
| --limit= | 50 | Most messages evaluated per mailbox. |
| --days= | 2 | Only consider mail stored in the last N days. |
| --dry-run | off | Decide 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.
| Option | Default | Effect |
|---|---|---|
| --all-clients | off | Walk every tenant in the master clients table. |
| --client= | — | One tenant, by client id. |
| --user= | — | One mailbox, by users.id. |
| --days= | 2 | Lookback window. |
| --limit= | 50 | Most messages per run. |
| --dry-run | off | Show what would happen; send nothing, store nothing. |
| --mark-seen | off | Draw a line under history. Records older mail as SKIPPED without calling the AI or sending. |
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
| Option | Default | Effect |
|---|---|---|
| --all-clients | off | Walk every tenant. |
| --client= | — | One tenant by client id. |
| --limit= | 25 | Maximum entries retried per tenant. |
| --max-attempts= | 3 | Leave 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.
| Option | Default | Effect |
|---|---|---|
| --all-clients | off | Walk 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-run | off | Show what would be raised; write nothing. |
| --no-close | off | Skip the answered-task pass. |
app:send-follow-up-reminders
| Option | Default | Effect |
|---|---|---|
| --all-clients | off | Walk every tenant. |
| --client= | — | One tenant by client id. |
| --owner= | — | One account by admin id. |
| --limit= | 200 | Most tasks included in one run. |
| --dry-run | off | Show 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.
| Option | Default | Effect |
|---|---|---|
| --user= | all | Only this mailbox. |
| --account= | all | Only mailboxes under this admin. |
| --dry-run | off | Report what would change without writing. |
| --chunk= | 500 | Rows 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.
| Option | Default | Effect |
|---|---|---|
| --email= | required | Mailbox to impersonate. |
| --user= | required | messages.user_id to attach restored mail to. |
| --after= | — | Gmail date syntax, e.g. 2025/01/01. |
| --before= | — | Gmail date syntax. |
| --limit= | 2000 | Stop after inserting this many. |
| --pages= | 50 | Maximum Gmail list pages to walk (500 per page). |
| --dry-run | off | Report 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:
| Table | Durable key | Matches |
|---|---|---|
| follow_ups | message_ref | messages.email_id |
| auto_reply_logs | message_ref | messages.email_id |
| thread_summaries | thread_key | messages.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
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.
| Cluster | Tables |
|---|---|
| Identity & org | users · departments · inbox_groups · impmail · domain · user_otps · password_reset_tokens |
| Mail core | messages · sent_mails · user_emails_id_data · promotion_mails · freshcounts |
| AI vocabularies | sentiments · next_actions · lead_types · email_categories · prompts |
| Auto reply | email_templates · auto_reply_settings · auto_reply_logs |
| Follow-up | follow_up_settings · follow_ups |
| Summaries | thread_summaries · mail_summaries · project_responses |
| Framework | migrations · sessions · cache · cache_locks · jobs · job_batches · failed_jobs |
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"
}
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
}
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
}
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
}
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
}
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"
}
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
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.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”.@gmail.com. Read when deciding whether a sender is internal, which auto-reply skips unless told otherwise.auth_id is a varchar defaulting to -1 and is not a foreign key.group_id. A group's mail listing is filter slug search-{group_id}.10.2Mail core
messages.sent_mail_id stops being -1 and the mail counts as replied.messages for every id.admin_id and emp_id are plain integers with no constraints.What will surprise you about messages
| Column | The catch |
|---|---|
| is_spam | Not a spam flag. It holds the sentiment's numeric legacy_code. Provider spam is detected from label_ids instead. |
| is_spam_value | The sentiment's word. This is the one the filters actually match on. |
| category / is_category_value | Same pairing: number then word. Legacy numbers are 1 Sales, 2 AMC, 3 External, 101 Unknown. |
| sent_mail_id | Default is the string '-1', meaning unreplied — not null, not zero. |
| email_id | The provider's message id, and the durable identity of a mail. Unlike id it survives a truncation. |
| next_action_source / lead_source | ai 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_summary | Null means not generated yet, not no summary. The app fills it lazily on view. |
| assigned_department_id | No foreign key. A deleted department leaves orphaned assignments. |
| gemini_return_json | The raw model response, kept for debugging a misclassification. |
| reply_draft | An AI draft awaiting a human. Its presence never causes a send. |
10.3Vocabularies, auto reply, follow-ups
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.user_id is required here, unlike the other three.status = 'approved' is ever sent; approved_by and approved_at record who signed it off.user_id. A missing row means the account never opted in, and the config/auto_reply.php defaults apply.body holds exactly what was delivered, so a customer complaint about an automated reply can be answered from the database.due_windows is JSON keyed by next_actions.code, overriding config/follow_up.php per action.dismissed row is as final as a done one — both stop the mail ever being raised again.10.4Generated content
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
| Key | Example | Notes |
|---|---|---|
| APP_URL | http://localhost | Base URL used in mailed links. |
| DB_CONNECTION | mysql | Default connection. The framework default is sqlite — this must be set. |
| DB_HOST / DB_PORT | localhost / 3306 | Also used by the clientdb connection. |
| DB_DATABASE | EmailInsight | The tenant database the web app serves. |
| DB_USERNAME / DB_PASSWORD | — | Shared by both tenant connections. |
| MASTER_DB_HOST | 127.0.0.1 | Master database host. |
| MASTER_DB_DATABASE | EmailInsight_master | Falls 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_PORT | smtp · smtp.gmail.com · 587 | Outbound SMTP for OTPs, reminders and digests. |
| MAIL_USERNAME / MAIL_PASSWORD | — | SMTP credentials. |
| MAIL_ENCRYPTION | tls | — |
| MAIL_FROM_ADDRESS / _NAME | — | Sender identity on system mail. |
Feature switches
| Key | Default | Effect |
|---|---|---|
| AUTO_REPLY_ENABLED | true | Master kill switch. When false the pipeline never sends, whatever any account's settings say. |
| AUTO_REPLY_CONFIDENCE | 0.80 | Fallback threshold for an account with no settings row. |
| AUTO_REPLY_DAILY_LIMIT | 3 | Fallback per-recipient daily cap. |
| AUTO_REPLY_GEMINI_URL / _TIMEOUT | flash · 30 s | Classifier endpoint and timeout. |
| GMAIL_SERVICE_ACCOUNT_FILE | storage/app/email/service-account.json | Domain-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_FILE | storage/app/email/outlook_account.json | Graph token cache. |
| FOLLOW_UP_ENABLED | true | Master kill switch. No task created, no reminder sent. |
| FOLLOW_UP_DEFAULT_HOURS | 48 | Window for an action with none of its own. |
| FOLLOW_UP_LEAD_HOURS | 2 | How long before due the first reminder goes out. |
| FOLLOW_UP_REPEAT_HOURS | 24 | Repeat interval while overdue. |
| FOLLOW_UP_MAX_REMINDERS | 4 | Hard stop on chasing. |
| FOLLOW_UP_ESCALATE_HOURS | 48 | Overdue by this much and the admin is copied in. 0 disables. |
| FOLLOW_UP_LOOKBACK_DAYS | 7 | Sweep candidate window. |
| FOLLOW_UP_BATCH_LIMIT | 200 | Messages evaluated per pass. |
| FOLLOW_UP_MAX_OPEN | 300 | Open tasks one mailbox may hold. At the cap the sweep stops creating rather than burying the real queue. |
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
due_windows table keyed by next-action code, the urgent_sentiments list that overrides priority, and the sweep safety limits.mysql, master, and clientdb with its deliberately empty database name.SECTION 12Backup and recovery
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:
- Reads database credentials from the project
.env— a single source of truth, so a password change does not silently break backups. - Asks the server for its database list, minus the virtual ones (
information_schema,performance_schema,sys) which cannot be restored. mysqldumps each database into its own self-contained.sql.- Verifies each dump finished, by checking for mysqldump's “Dump completed” trailer — a truncated dump would otherwise look like a perfectly fine file.
- Zips the night into one archive and deletes the loose
.sqlfiles. - Deletes archives older than the retention window.
| Parameter | Default | Notes |
|---|---|---|
| -BackupRoot | C:\xampp8_2\db-backups | Must 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. |
| -RetentionDays | 14 | About a few hundred MB of history. |
| -MysqlBin | C:\xampp8_2\mysql\bin | — |
| -ProjectRoot | C:\xampp8_2\htdocs\EmailInsightAI | Where 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.
- 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.
- Restore from the most recent nightly dump if one covers the loss.
- For anything the dump does not cover, page it back out of the provider:
then re-run withoutphp artisan mail:backfill-gmail --email=ami@mukesoft.com --user=1 \ --after=2025/01/01 --before=2026/09/07 --dry-run--dry-run. Mail is inserted oldest-first so theid DESClistings stay in the right order. - 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 - Re-apply routing to the restored mail once something has classified it:
php artisan mail:assign-existing --dry-run
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
| Symptom | Check, in this order |
|---|---|
| No new mail is appearing | 1. 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 resumed | The 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 labels | The 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 nothing | Walk 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 thing | Open 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 team | Assignment 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 head | Confirm 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 tasks | The 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 task | Expected once reminder_count hits max_reminders: remind_at is nulled and the task stays open but stops chasing. |
| A thread summary looks out of date | It is cached and marked stale when the conversation grows. Use the regenerate control rather than deleting the row. |
| A filter slug shows the wrong list | Resolution 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 hour | Expected at :00. If throughput suffers, stagger the hourly jobs in Kernel.php. The withoutOverlapping() guard prevents compounding but not contention. |
php artisan route:list fails | Known and unfixed. Read routes/web.php directly. |
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
auth_id = 0) and every mailbox beneath them. Configuration belongs here.users that mail is synced into. Mail belongs here.master.clients.{type} segment of /mails/{type}. Prefixed per vocabulary so the five cannot collide.min_score below which it is not counted.lead_types.priority — never stored on the mail.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.