Helpdesk Reference Public

Tickets: Email & Automation

Email-to-ticket via IMAP, outbound notifications, agent signatures, and the auto-close-inactive-tickets automation.

Guide version: r1 Module version: 0.1.0 Updated: 2026-07-22 Estimated time: 15 min 1 views

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.

Support Agent HR Admin System Administrator Developer / DevOps
Where this lives Everything on this page ships inside 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:

Mailbox reader
ddeboer/imap (^1.6)
Reply parser
willdurand/email-reply-parser (^2.8)
Runtime requirement
PHP ext-imap extension
Reader class
modules/Helpdesk/libraries/Imap.php

The 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.

Incoming mailarrives in mailbox
AuthenticateIMAP INBOX
Unread onlyisSeen() check
Match subjectfind #id?
Create / appendticket or comment
Mark seenmarkAsSeen()

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 subjectDetected ticket idResult
Re: Cannot log in [#128 ]128 (exists)Appended as a comment on ticket #128 → ticket_commented
Re: Ticket #9999 please help9999 (no such ticket)No open ticket #9999 exists → treated as a brand-new ticket (the reply is still reply-parsed)
Printer is brokennoneNew ticket, title = the subject → ticket_created
(empty subject)noneNew ticket, title = the sender's email address
Keep the #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.

Slug
run_imap
Title
Poll IMAP mailboxes
Schedule
* * * * * (every minute)
Timeout
120 seconds
Memory
128 MB
Enabled by default
Yes
Global scope
No (runs per-tenant)

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:

CommandWhat it does
php erpat cron:list
List every registered job, its slug and schedule — you should see run_imap and close_inactive_tickets.
php erpat cron:tick
Advance the scheduler: run whatever jobs are currently due (this is what your OS-level crontab / task scheduler should call every minute).
php erpat cron:run run_imap
Force-run the mailbox poll now, ignoring the schedule (still respects the enablement + throttle guards inside the job).
php erpat cron:dry-run run_imap
Show what the job would do without committing side effects.
SOP — Schedule the Advanced Cron runtime (once per environment)
  1. On the server, add a single OS-level scheduled task that runs php erpat cron:tick every minute (Linux crontab: * * * * * php /path/to/erpat cron:tick; Windows: a Task Scheduler entry on a 1-minute trigger).
  2. Confirm the runtime sees the jobs with php erpat cron:list — verify run_imap is present and marked enabled.
  3. Do not add a separate crontab line per job — the single cron:tick drives all of them on their own schedules.
  4. 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 imap extension is loaded (extension_loaded("imap")). If it is missing, the form is replaced with the imap_extension_error_help_message notice.

Field reference

FieldSetting keyWhat it controls
Enable email pipingenable_email_pipingMaster 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 emailscreate_tickets_only_by_registered_emailsWhen on, mail from an address that does not match a registered client contact is silently ignored.
SSL enabledimap_ssl_enabledYes/No dropdown. When Yes, the connection uses the IMAP SSL flag with certificate validation.
Hostimap_hostMail server hostname (e.g. your provider's IMAP host). Required.
Portimap_portIMAP port (commonly 993 for SSL, 143 for plain). Required.
Emailimap_emailThe mailbox login address that mail is read from. Required, validated as an email.
Passwordimap_passwordMailbox password (or app password). Stored encrypted; the field is never pre-filled on load.
Status (read-only)imap_authorizedShows Authorized or an Unauthorized label based on whether the last authorization attempt succeeded.
Save vs. Save & Authorize When email piping is off the footer shows a plain Save button. When it is on the footer shows Save & Authorize — saving then redirects through 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.
SOP — Configure email support end-to-end
  1. Confirm the server has PHP ext-imap installed and Composer dependencies merged (ddeboer/imap + willdurand/email-reply-parser). If ext-imap is missing, the settings screen shows the extension warning and no form.
  2. Provision a dedicated support mailbox (e.g. support@yourcompany) with an IMAP-capable password / app password.
  3. Open Tickets settings → IMAP tab. Turn on Enable email piping.
  4. Fill in Host, Port, SSL (Yes for 993), the mailbox Email, and the Password.
  5. Decide whether to restrict intake to known clients via Create tickets only from registered emails.
  6. Click Save & Authorize. The page redirects through the authorization step; on success the Status label reads Authorized and imap_authorized is set to 1.
  7. Ensure the Advanced Cron runtime is scheduled (see the cron SOP above) so run_imap actually polls.
  8. Verify by sending a test email into the mailbox and confirming a ticket appears (allow for the 10-minute poll throttle).
Tie outbound replies back to the mailbox The settings help text points admins at the core Email Templates settings screen so the from-address and reply chrome on ticket notifications match the support mailbox — that keeps replies threading back into the same inbox the 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.

Slug
close_inactive_tickets
Title
Close inactive tickets
Schedule
30 2 * * * (daily, 02:30)
Timeout
600 seconds
Driven by
auto_close_ticket_after (days)
Enabled by default
Yes

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).

Known caveat — closes by created date, not last activity Despite the intent of the feature (close tickets that have been inactive), the current job filters on 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.
Reopening is easy An auto-closed ticket is not deleted — it is simply closed. A new client reply flips it to client_replied, and staff can reopen it to open from the ticket options. See Tickets — Lifecycle & Management for the full status flow.

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 hookFires whenSource
ticket_createdA 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_assignedA ticket is assigned to a user — on save when the assignee changes, and via the assign-to actions.Tickets controller
removed_as_a_ticket_assigneeThe current assignee is cleared or replaced (the old assignee is notified they were removed).Tickets controller
ticket_commentedA 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_ticketA comment @-mentions one or more users (the reply body is scanned for @[Name:id] tokens).Tickets controller
ticket_closedA ticket is closed — manually via the status action, or by the close_inactive_tickets job (attributed to system).Tickets controller / CloseInactiveTicketsJob.php
ticket_reopenedA closed/on-hold ticket is moved back to open.Tickets controller
ticket_on_holdA ticket is put on hold.Tickets controller
added_as_a_ticket_collaboratorOne or more users are added as collaborators on a ticket save.Tickets controller
removed_as_a_ticket_collaboratorOne or more users are removed from a ticket's collaborators.Tickets controller
Status side-effect on inbound replies When a client comments (including through an email reply), the ticket moves to client_replied. When a staff member comments on a 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.

Signatures are personal, not global There is no company-wide signature here — each key is namespaced to the individual user (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.

CapabilityGate
Access the Tickets pillar at allModule 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 settingsAdministrative Settings access (Tickets settings tab)
Set a personal reply signatureAny authenticated staff user (per-user setting)

Runtime requirements & gotchas

Prerequisites that will silently stop email intake
  • PHP ext-imap must be installed. The ddeboer/imap reader needs it at runtime; without it the settings form is replaced with a warning and nothing polls.
  • Composer deps must be merged. ddeboer/imap and willdurand/email-reply-parser are declared in modules/Helpdesk/composer.json and merged into the shared vendor/. Run composer install if the classes are missing.
  • Cron must be scheduled. No cron:tick means run_imap never runs and no email becomes a ticket.
  • Authorization must succeed. If imap_authorized is 0, 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

Was this guide helpful?

Report a content problem