The Mail Support Assistant is an admin-configured support-mail workflow for Tools and the standalone PHP client stored under public/tornevall-tools-mail-assistant.
Before the Mail Support Assistant can actually work in production, you need these prerequisites in place:
/admin/mail-support-assistantprovider_mail_support_assistantis_ai=1)provider_openai access for the token owner if any rule or unmatched fallback uses AI and the owner is not adminprovider_mail_support_assistant_mailer if outgoing mail should be sent through POST /api/mail-support-assistant/send-replymail-support-assistant.relay for the relay-token owner when Tools relay should be allowed for non-admin usersmail()storage/ for logs, summaries, and optional local stateIf you need help, want a change, or need to report a problem in the standalone client, use GitHub tickets here:
That repository is the right place for runtime/client issues, setup clarifications, and feature requests related to the standalone Mail Support Assistant.
Admin-only configuration is available at:
/admin/mail-support-assistantFrom there you can:
provider_mail_support_assistant)provider_mail_support_assistant_mailer) for outgoing mail relay via ToolsMailbox settings now also include an optional generic AI reply for unmatched emails section. This lets admins enable mailbox-level fallback reply paths for mail that does not match any explicit rule, including:
The Tools admin UI now also supports lightweight AJAX saves for mailbox/rule create/update/delete actions, so common edits no longer have to bounce the whole page through a full form submit. Unmatched fallback IF rows now save in place through AJAX with local busy/success feedback under the row button, and delete also removes the row in place with immediate empty-state restore when the last row is removed.
Important precedence note:
sort_order and can fall through to later rows if a prior row is rejectedsort_order)ai_enabled=true, the standalone client now sends that rule's responder/persona/custom instruction/model/reasoning values to Tools as explicit per-request AI overrides\\Seen, which reduces accidental read-state drift caused by mailbox fetchesIn-Reply-To / References link a follow-up mail to an earlier handled conversation, the standalone runner can now also reuse the earlier matched rule or prioritize the earlier unmatched fallback row so the thread stays on the same support pathreply_message_id in local state, which makes later Gmail/Outlook follow-ups more likely to reconnect to the same conversation even when the user replies to the assistant's sent mail instead of the original inbound message[Ärende MSA-ABC12345]), and later replies reuse that same tag instead of appending a new one every time the conversation is answeredIn-Reply-To / References are missing, the standalone runner can now still try a normalized subject + same participants fallback before treating the message as a fresh no-match conversation429 / Too Many Attempts) are retried automatically before the standalone client gives up on that rule's AI replytemplate_text fallback, the standalone client no longer sends the old generic sentence Thank you for your message. We have reviewed it. when AI fails; it now aborts that reply and logs the error insteadFrom:, Subject:, Sender IP:, and Message Body: now preserve the actual problem paragraph in standalone summaries/AI context instead of collapsing to only the first header-like lineThe generated token is stored as a personal api_keys row and is automatically marked AI-capable (is_ai=1).
Relay tokens (provider_mail_support_assistant_mailer) are personal non-global keys and are intended only for
POST /api/mail-support-assistant/send-reply. They are not AI-receiver tokens.
The admin UI now also uses neutral support-oriented example values (order status, customer care, reference numbers, processed folders) so new rules do not start from a too-specific legal/copyright scenario.
/admin/mail-support-assistant and open a dedicated threaded case page from there.MAIL_ASSISTANT_UNANSWERED_REPORT_ENABLED=trueMAIL_ASSISTANT_UNANSWERED_REPORT_TO=user@example.com[,second@example.com]GET /api/mail-support-assistant/cases
GET /api/mail-support-assistant/configPOST /api/mail-support-assistant/cases/sync
GET /api/mail-support-assistant/configmailbox_id, message_id, message_key, reply_issue_id, thread_key, subject, from, to, status, reason, additive body_text, additive body_text_reply_aware, additive body_html, body_excerpt, optional reply_message_id, additive reply_body_text, additive reply_body_html, and optional reply_excerptBecause the standalone client fetches mailbox and rule config directly from Tools by bearer token, many deployments only need the CLI runner or cron job. The small PHP dashboard is optional and does not normally have to be exposed publicly.
That same CLI/dashboard runner stack is now overlap-safe too: each run acquires a local non-blocking lock file under storage/state/run.lock, so if cron or the dashboard tries to start another run while one is already active, the second attempt is skipped cleanly with a runner_already_active conflict instead of polling the same unread mailbox twice.
The shell wrapper cron-run.sh now also keeps its own PID-aware lock directory (default storage/state/cron-run.lock.d) with stale-lock cleanup, so overlapping cron invocations can be rejected even before the PHP runner starts. That shell lock path can be overridden through MAIL_ASSISTANT_CRON_LOCK_DIR when several deployments share the same filesystem.
The standalone dashboard is now also more operator-friendly than before:
That dashboard should still be treated as a lightweight operator surface, not as a full replacement for the real Tools admin UI.
The standalone PHP client should use a personal bearer token such as:
provider_mail_support_assistantImportant rules:
is_ai=1provider_openai secret is not a receiver token and is automatically excluded from is_aiprovider_openai access unless the owner is adminGET /api/mail-support-assistant/configAuth:
Authorization: Bearer <personal AI-capable token>X-Api-Key: <personal AI-capable token>apikey=<personal AI-capable token>Expected token model:
is_ai=1) or legacy tools_ai_bearerSuccess response:
{
"ok": true,
"message": "Mail Support Assistant config loaded.",
"user": {
"id": 1,
"name": "Admin User",
"email": "admin@example.com",
"has_openai_access": true,
"ai_daily_budget": {
"feature": "social_media_extension",
"default_model": "gpt-4o-mini",
"max_output_tokens_default": 800,
"cap": 60000,
"used": 4200,
"remaining": 55800,
"is_unlimited": false,
"source": "user_override"
}
},
"token": {
"provider": "provider_mail_support_assistant",
"user_id": 1,
"is_personal": true,
"is_ai": true
},
"mailboxes": [
{
"id": 5,
"name": "Main inbox",
"imap": {
"host": "imap.example.com",
"port": 993,
"encryption": "ssl",
"username": "support@example.com",
"password": "...",
"folder": "INBOX"
},
"defaults": {
"from_name": "Support Team",
"from_email": "support@example.com",
"bcc": "audit@example.com",
"footer": "Kind regards",
"run_limit": 20,
"mark_seen_on_skip": false,
"spam_score_reply_threshold": 6.5,
"generic_no_match_ai_enabled": true,
"generic_no_match_ai_model": "gpt-4o-mini",
"generic_no_match_ai_reasoning_effort": "medium",
"generic_no_match_if": "If the unmatched mail is a normal support request and clearly not spam, fraud, phishing, or vague sales outreach, the final fallback may answer.",
"generic_no_match_instruction": "Reply briefly, ask for the missing account detail, and stay on the sender's original language.",
"generic_no_match_footer": "Kind regards",
"generic_no_match_rules": [
{
"id": 41,
"sort_order": 0,
"is_active": true,
"if": "If the unmatched mail is an unsolicited sales pitch, we should decline.",
"instruction": "Decline politely and do not invite follow-up.",
"footer": "Kind regards",
"ai_model": "gpt-4o-mini",
"ai_reasoning_effort": "medium"
}
]
},
"rules": [
{
"id": 11,
"name": "Order status reply",
"match": {
"from_contains": "customer@example.com",
"to_contains": "support@example.com",
"subject_contains": "order status",
"body_contains": "order number"
},
"reply": {
"enabled": true,
"ai_enabled": true,
"subject_prefix": "Re:",
"from_name": "Customer Care",
"from_email": "care@example.com",
"bcc": "archive@example.com",
"template_text": null,
"footer_mode": "static",
"footer_text": "Kind regards",
"responder_name": "Alex",
"persona_profile": "Helpful support agent who writes short, clear responses.",
"mood": "calm",
"custom_instruction": "Summarize the request and ask for any missing reference number.",
"ai_model": "gpt-4o-mini",
"ai_reasoning_effort": "medium"
},
"post_handle": {
"move_to_folder": "Processed",
"delete_after_handle": false
}
}
]
}
]
}
Errors:
401 when the bearer token is missing or rejectedPOST /api/mail-support-assistant/send-replyPurpose:
mail()/MTA.Auth:
provider_mail_support_assistant_mailermail-support-assistant.relay (admin bypass applies)Body (example):
{
"mailbox_id": 5,
"rule_id": 11,
"mode": "fallback_after_php_mail",
"to": "customer@example.com",
"cc": [],
"bcc": ["audit@example.com"],
"from": "Support Team <support@example.com>",
"subject": "Re: Order status",
"body": "Thanks for your message.",
"body_html": "<html><body><p>Thanks for your message.</p></body></html>",
"message_meta": {
"message_id": "<abc@example.com>",
"uid": 12345
}
}
Success response:
{
"ok": true,
"message": "Reply relayed via Tools mail transport.",
"relay": {
"provider": "provider_mail_support_assistant_mailer",
"user_id": 1,
"mailbox_id": 5,
"rule_id": 11,
"mode": "fallback_after_php_mail"
}
}
Errors:
401 when relay token is missing/invalid/inactive403 when token owner lacks mail-support-assistant.relay422 for invalid mail payload fieldsAdditive relay field:
body_html is optional.multipart/alternative with the original plain-text body plus the HTML part from body_html.X-Tornevall-Mail-Assistant: sent for anti-loop detection in standalone polling.storage/.MAIL_ASSISTANT_MAIL_TRANSPORT=smtp) and can be tuned with MAIL_ASSISTANT_SMTP_* keys, so local Postfix/sendmail is no longer required in default setups.MAIL_ASSISTANT_TOOLS_SSL_VERIFY, MAIL_ASSISTANT_TOOLS_CA_BUNDLE) and SMTP (MAIL_ASSISTANT_SMTP_SSL_VERIFY, MAIL_ASSISTANT_SMTP_CA_FILE).MAIL_ASSISTANT_DEFAULT_BCC from its local .env file.MAIL_ASSISTANT_MAIL_FALLBACK_TRANSPORTS.tools_api but the dedicated relay token is missing, the standalone runner now skips relay mode and continues with SMTP/local fallback transports instead of aborting the reply.POST /api/mail-support-assistant/send-reply.multipart/alternative: a plain-text body is always kept for compatibility, and a styled HTML version is added for mail clients that prefer rich formatting.Best regards followed by Regards.X-Tornevall-Mail-Assistant: sent as assistant-originated loop candidates: they are skipped before rule matching/reply and marked seen to avoid endless self-reply retries.defaults.generic_no_match_* fields returned by /api/mail-support-assistant/config.generic_no_match_if / generic_no_match_instruction fields now represent the mailbox's own last fallback and are no longer aliases for the first advanced unmatched row.defaults.generic_no_match_rules[] and are still checked before that last mailbox-level fallback.defaults.generic_no_match_ai_reasoning_effort.defaults.generic_no_match_if.can_reply=true, certainty="high", and a non-empty reply payload. Any other result leaves the message untouched.generic_ai_decision.evaluated_no_match_rules[], so operators can review which unmatched fallback rows were actually tried, in order, before a reply was sent or the mail remained unread.thread_key, in_reply_to, and references[], which makes it easier to see whether a skipped message actually carried usable reply-chain metadata.reply.responder_name, reply.persona_profile, reply.custom_instruction, reply.ai_model, and additive reply.ai_reasoning_effort as authoritative AI overrides for that one reply instead of silently looking like a plain static-template response.response_language=auto) unless the sender clearly asks for another language.o4; that fallback retry intentionally omits reasoning_effort metadata so o4 behaves as a plain non-reasoning fallback here.GET /api/mail-support-assistant/config can now also expose additive user.ai_daily_budget metadata so the standalone client/operator can inspect the effective daily AI token cap, remaining budget, default model, and default max-output-token setting used by Mail Support Assistant's SocialGPT-backed reply path.reply.template_text fallback is configured, the client now leaves that reply unsent and logs the error instead of emitting a misleading generic canned answer.mark_seen_on_skip is enabled.MAIL_ASSISTANT_QUOTA_ALERT_EMAIL.mark_seen_on_skip should therefore be treated as a deliberate heuristic-spam convenience for cases like high-score SpamAssassin skips, not as a way to auto-dismiss configuration-driven no-match mail.storage/state/message-state.json file is now diagnostic history only. An unread message may still be re-evaluated on a later run even if the same Message-Id already exists in that file, which means newly added rules can apply without clearing local state first.reply_message_id values and can fall back to normalized subject + same participants when explicit reply headers were lost by an older mail format or intermediary rewrite.spam_score_reply_threshold; when a message score is above this value, standalone suppresses reply handling for that message and keeps it unread.X-Spam-Score directly when X-Spam-Status does not include a parseable score= token..eml header dumps or SpamAssassin wrapper text do not become the visible quoted request summary.