Tickets: Email & Automation
Turn an ordinary support mailbox into a ticket pipeline. This page covers email-to-ticket ingestion over IMAP, the two Helpdesk cron jobs that drive it (run_imap and close_inactive_tickets), the outbound notification hooks tickets fire, and the per-agent signature that gets stamped onto replies.
modules/Helpdesk/ — the IMAP reader is libraries/Imap.php, the polling job is jobs/RunImapJob.php, the auto-close job is jobs/CloseInactiveTicketsJob.php, the mailbox settings screen is views/settings/tickets/imap_settings.php, and the agent signature form is views/tickets/signature/modal_form.php. Access still requires the Helpdesk master toggle module_helpdesk plus the Tickets pillar toggle module_ticket and the ticket permit.Email-to-Ticket: how a message becomes a ticket
ERPat can read a dedicated support mailbox and convert incoming mail into tickets automatically. The reader is the Imap library, which stands on two Composer packages declared in modules/Helpdesk/composer.json:
ddeboer/imap (^1.6)willdurand/email-reply-parser (^2.8)ext-imap extensionmodules/Helpdesk/libraries/Imap.phpThe ingestion pipeline
On each run the reader authenticates against the mailbox, opens the INBOX, and walks every message. Only unread messages are processed; once ingested a message is marked as seen so it is never handled twice.
isSeen() check#id?markAsSeen()1. Authenticate & open the inbox
run_imap() connects with the stored host, port, encryption flag, mailbox address and password (the password is decrypted server-side via decode_password()), then opens the INBOX mailbox. A successful connect sets the imap_authorized setting to 1; a failure logs the error and flips it back to 0.
2. Loop unread messages
Every message in the inbox is inspected. If isSeen() is false, it is passed to the ticket builder; if it has already been read it is skipped. This is what makes the poll idempotent.
3. Identify the sender
The sender's email address is looked up against registered client contacts (matching on email, user_type = "client", deleted = 0). When the create_tickets_only_by_registered_emails setting is on and the sender is not a known client, the message is ignored — nothing is created.
4. New ticket vs. reply
The subject is scanned for a # followed by a numeric ticket id. If that id matches an existing, non-deleted ticket, the message is treated as a reply and appended as a comment. Otherwise it becomes a new ticket, using the subject as the title (falling back to the sender's email when there is no subject).
5. Parse the body & strip quoted history
For replies, the body text is run through EmailReplyParser so the quoted history and prior thread are stripped — only the person's new message survives. If the parsed body comes back empty, the reader falls back to decoding the raw message by its transfer encoding (7bit, base64, quoted-printable).
6. Save attachments
Every attachment is downloaded and moved into the ticket timeline file path (timeline_file_path) via move_temp_file(), then serialized onto the comment's files field so it appears exactly like an in-app upload.
7. Notify & mark as read
A new ticket fires the ticket_created notification; a reply fires ticket_commented (both exclude the ticket creator so the sender is not emailed their own message back). Finally the message is marked as seen.
Subject-line matching (reply detection)
Whether a mail becomes a new ticket or a comment on an existing one comes down to the subject. The parser looks for a # character and reads the number that follows it up to the next space.
| Incoming subject | Detected ticket id | Result |
|---|---|---|
Re: Cannot log in [#128 ] | 128 (exists) | Appended as a comment on ticket #128 → ticket_commented |
Re: Ticket #9999 please help | 9999 (no such ticket) | No open ticket #9999 exists → treated as a brand-new ticket (the reply is still reply-parsed) |
Printer is broken | none | New ticket, title = the subject → ticket_created |
(empty subject) | none | New ticket, title = the sender's email address |
#id intact Reply detection is entirely subject-based. If a mail client rewrites or drops the #128 token from the subject, the reply will be opened as a new ticket instead of threading onto the original. Outbound ticket notifications include the id so agents and clients should reply without editing the subject.The run_imap cron job
The mailbox is not polled by a web request — it is driven by a scheduled job on ERPat's Advanced Cron runtime. The job class is Modules\Helpdesk\Jobs\RunImapJob.
run_imap* * * * * (every minute)Although the cron entry ticks every minute, the job protects itself with two guards before it ever touches the mailbox:
Enablement gate
If enable_email_piping is off or imap_authorized is not set, the job logs [run_imap] skipped: not enabled / not authorized and returns immediately.
10-minute throttle
The job records last_cron_job_time_of_imap after each real poll. If less than 600 seconds have passed since the last poll, it logs [run_imap] skipped: throttled (10-minute window) and returns. So even on a per-minute cron, the mailbox is actually read at most once every 10 minutes.
Poll
When both guards pass, it loads the imap library, calls run_imap(), saves the new run timestamp, and logs [run_imap] processed.
Advanced Cron runtime commands
ERPat's Advanced Cron runtime discovers module-owned jobs automatically (the JobRegistry picks up modules/Helpdesk/jobs/*). Use the erpat CLI to inspect and drive them:
| Command | What it does |
|---|---|
| List every registered job, its slug and schedule — you should see run_imap and close_inactive_tickets. |
| Advance the scheduler: run whatever jobs are currently due (this is what your OS-level crontab / task scheduler should call every minute). |
| Force-run the mailbox poll now, ignoring the schedule (still respects the enablement + throttle guards inside the job). |
| Show what the job would do without committing side effects. |
- On the server, add a single OS-level scheduled task that runs
php erpat cron:tickevery minute (Linux crontab:* * * * * php /path/to/erpat cron:tick; Windows: a Task Scheduler entry on a 1-minute trigger). - Confirm the runtime sees the jobs with
php erpat cron:list— verifyrun_imapis present and marked enabled. - Do not add a separate crontab line per job — the single
cron:tickdrives all of them on their own schedules. - Because the runtime runs per-tenant (both jobs report
runsForGlobalScope() = false), ensure your tick invocation covers every tenant database that has the Helpdesk module enabled.
IMAP mailbox settings
The mailbox is configured under the Tickets settings area, on the IMAP tab (views/settings/tickets/imap_settings.php, saved by the settings/save_imap_settings action). The screen first checks two runtime prerequisites and only shows the form when both pass:
- PHP version ≥ 7.0.0 (ERPat itself runs PHP 8.2+, so this always passes).
- The PHP
imapextension is loaded (extension_loaded("imap")). If it is missing, the form is replaced with theimap_extension_error_help_messagenotice.
Field reference
| Field | Setting key | What it controls |
|---|---|---|
| Enable email piping | enable_email_piping | Master on/off switch for email-to-ticket. When off, the IMAP detail fields are hidden and the run_imap job short-circuits. Toggling it on reveals the mailbox fields and the Save & Authorize button. |
| Create tickets only from registered emails | create_tickets_only_by_registered_emails | When on, mail from an address that does not match a registered client contact is silently ignored. |
| SSL enabled | imap_ssl_enabled | Yes/No dropdown. When Yes, the connection uses the IMAP SSL flag with certificate validation. |
| Host | imap_host | Mail server hostname (e.g. your provider's IMAP host). Required. |
| Port | imap_port | IMAP port (commonly 993 for SSL, 143 for plain). Required. |
imap_email | The mailbox login address that mail is read from. Required, validated as an email. | |
| Password | imap_password | Mailbox password (or app password). Stored encrypted; the field is never pre-filled on load. |
| Status (read-only) | imap_authorized | Shows Authorized or an Unauthorized label based on whether the last authorization attempt succeeded. |
settings/authorize_imap, which actually connects to the mailbox and sets the imap_authorized flag. You must authorize successfully before the run_imap job will do anything.- Confirm the server has PHP
ext-imapinstalled and Composer dependencies merged (ddeboer/imap+willdurand/email-reply-parser). Ifext-imapis missing, the settings screen shows the extension warning and no form. - Provision a dedicated support mailbox (e.g.
support@yourcompany) with an IMAP-capable password / app password. - Open Tickets settings → IMAP tab. Turn on Enable email piping.
- Fill in Host, Port, SSL (Yes for 993), the mailbox Email, and the Password.
- Decide whether to restrict intake to known clients via Create tickets only from registered emails.
- Click Save & Authorize. The page redirects through the authorization step; on success the Status label reads Authorized and
imap_authorizedis set to1. - Ensure the Advanced Cron runtime is scheduled (see the cron SOP above) so
run_imapactually polls. - Verify by sending a test email into the mailbox and confirming a ticket appears (allow for the 10-minute poll throttle).
run_imap job reads.Auto-close inactive tickets
The second automation closes tickets that have gone quiet. The job class is Modules\Helpdesk\Jobs\CloseInactiveTicketsJob.
close_inactive_tickets30 2 * * * (daily, 02:30)auto_close_ticket_after (days)What it does, step by step
Read the threshold
The job reads the auto_close_ticket_after setting (a number of days configured on the Tickets settings tab). If it is empty/zero the job logs [close_inactive_tickets] skipped: auto_close_ticket_after not set and does nothing.
Compute the cutoff date
It subtracts that many days from today to get a cutoff, then selects tickets whose status is new or open and whose date falls on or before the cutoff (using the model's created_date_or_before filter).
Post a closing note
For each matching ticket it first saves a system comment (lang('ticket_auto_closed_due_to_inactivity'), authored by created_by = 0) so the timeline records why the ticket was closed.
Close the ticket
It then sets the ticket status to closed and stamps closed_at with the current UTC time.
Notify
Each closed ticket fires a ticket_closed notification (attributed to system). The job finishes by logging [close_inactive_tickets] closed N ticket(s).
created_date_or_before — i.e. it targets tickets whose creation date is older than the threshold, not their last_activity_at. A ticket created long ago but still receiving comments can therefore be swept into a close. This is a documented issue in the module implementation plan (queued fix: switch the query to last_activity_at and add a pre-close notice plus a short reopen grace). Until that lands, tune auto_close_ticket_after conservatively.Outbound notifications & hooks
Tickets emit events through the shared log_notification() pipeline so the right people are alerted at each step. The email/automation paths and the in-app actions both feed the same hooks. The table below lists every ticket hook the module preserves and when it fires.
| Notification hook | Fires when | Source |
|---|---|---|
ticket_created | A new ticket is created — from the in-app form, or by the IMAP reader for a brand-new inbound email (the email path excludes the creator). | Tickets form / Imap.php |
ticket_assigned | A ticket is assigned to a user — on save when the assignee changes, and via the assign-to actions. | Tickets controller |
removed_as_a_ticket_assignee | The current assignee is cleared or replaced (the old assignee is notified they were removed). | Tickets controller |
ticket_commented | A comment is added — from the in-app reply form, or by the IMAP reader when an inbound email replies to an existing ticket (excludes the ticket creator). | Tickets controller / Imap.php |
mentioned_on_a_ticket | A comment @-mentions one or more users (the reply body is scanned for @[Name:id] tokens). | Tickets controller |
ticket_closed | A ticket is closed — manually via the status action, or by the close_inactive_tickets job (attributed to system). | Tickets controller / CloseInactiveTicketsJob.php |
ticket_reopened | A closed/on-hold ticket is moved back to open. | Tickets controller |
ticket_on_hold | A ticket is put on hold. | Tickets controller |
added_as_a_ticket_collaborator | One or more users are added as collaborators on a ticket save. | Tickets controller |
removed_as_a_ticket_collaborator | One or more users are removed from a ticket's collaborators. | Tickets controller |
new or closed ticket they are involved in, it moves to open. Note the client-facing UI hides the client_replied label and shows it as open to clients.Agent signatures
Each staff member can set a personal signature that they append to their ticket replies. The signature is a per-user setting, edited from the small Tickets settings modal (views/tickets/signature/modal_form.php, opened via tickets/settings_modal_form and saved by tickets/save_settings).
Open the signature editor
From the Tickets area, open the personal ticket settings modal. It shows a single rich-text Signature field, pre-filled from the current value.
Compose & save
Type your signature in the rich-text editor and Save. The value is stored under the per-user setting key user_{userId}_signature (setting type user), so every agent has their own.
Use it in replies
Because the signature is scoped to the signed-in user, the reply UI can pull user_{userId}_signature and stamp it onto the agent's outgoing comment/reply, keeping a consistent sign-off across every ticket that agent answers.
user_{userId}_signature). Admins wanting a uniform footer on all outbound ticket mail should use the Email Templates settings instead.Permissions & requirements
Email and automation are governed by the Tickets pillar. The mailbox and automation settings are administrative; ingested mail and replies flow through the same ticket permits as any other ticket. See the Permissions Reference for the complete matrix.
| Capability | Gate |
|---|---|
| Access the Tickets pillar at all | Module toggle module_ticket enabled and the ticket permit |
| Read / act on tickets, add comments (incl. those created from email) | ticket_comment (or being the assignee / requester / collaborator / creator) |
| Manage tickets (assign, hold, reopen, close) | ticket_manage |
| Configure IMAP / email piping & ticket settings | Administrative Settings access (Tickets settings tab) |
| Set a personal reply signature | Any authenticated staff user (per-user setting) |
Runtime requirements & gotchas
- PHP
ext-imapmust be installed. Theddeboer/imapreader needs it at runtime; without it the settings form is replaced with a warning and nothing polls. - Composer deps must be merged.
ddeboer/imapandwilldurand/email-reply-parserare declared inmodules/Helpdesk/composer.jsonand merged into the sharedvendor/. Runcomposer installif the classes are missing. - Cron must be scheduled. No
cron:tickmeansrun_imapnever runs and no email becomes a ticket. - Authorization must succeed. If
imap_authorizedis0, the poll skips regardless of the cron schedule. - The 10-minute throttle is real. Even with a per-minute cron, mail is picked up at most once every 10 minutes — a fresh test email may not appear instantly.
Troubleshooting
Walk the guards in order: (1) Is enable_email_piping on? (2) Does the Status label read Authorized (is imap_authorized = 1)? Re-run Save & Authorize if not. (3) Is the Advanced Cron runtime scheduled — does php erpat cron:list show run_imap enabled? Try php erpat cron:run run_imap to force a poll. (4) Have you waited past the 10-minute throttle? (5) Is ext-imap installed on the server?
create_tickets_only_by_registered_emails is likely on. In that mode, mail from an address that does not match a registered client contact (by email, user_type = "client", not deleted) is dropped. Turn the setting off, or register the sender as a client contact.
Reply matching is subject-based on the #id token. If the client's mail app changed the subject or stripped the #128 marker, the reply is treated as a new ticket. Ensure outbound notifications include the id and ask users to reply without editing the subject.
Replies are run through EmailReplyParser to strip quoted history, but if the parsed body comes back empty the reader falls back to decoding the raw message (by 7bit/base64/quoted-printable) which can include quoted text. Unusual mail formats are the typical cause.
Remember the known caveat: close_inactive_tickets currently targets tickets by their creation date via created_date_or_before, not their last activity. Long-lived-but-active tickets can be caught. Raise auto_close_ticket_after, or set it to 0 to disable auto-close entirely until the query fix ships.
The screen refuses to render the form unless PHP ≥ 7.0.0 and the imap extension is loaded. On ERPat the PHP version always passes, so the culprit is a missing ext-imap. Install/enable it and reload.
Related pages
Tickets — Lifecycle
Statuses, assignment, comments, collaborators and the full ticket flow.
Permissions Reference
Every ticket* permit and how the pillar toggles gate access.
Developer APIs
The v1/api/me/tickets end-user API for programmatic ticket read/write.
FAQ & Troubleshooting
More common questions across the whole Helpdesk module.