Zammad Integration
Overview
Verified contact form submissions are forwarded to the Zammad helpdesk as tickets via the Zammad REST API. A ticket is created only after the Reoon email verification passes — failed verifications never reach Zammad.
Notification scope: Zammad is used for ticket intake only. Telegram and email notifications are sent by the contact form snippet on WordPress (see Telegram Notifications and Email Notifications); Zammad does not trigger those. Agents reply to customers inside Zammad via the email channel.
Architecture
Zammad Setup
- Host: Zammad 7.x runs as a Docker Compose stack on GSM16 (
/opt/docker-data/apps/zammad/), exposed via Cloudflare tunnel athttps://zammad.id86.net— see Zammad Stack - Group:
opshell.dev— target group for contact-form tickets, assigned outbound emailsupport@opshell.dev - Authentication: HTTP token auth (
Authorization: Token token=...) for an agent withticket.agentpermission - Token, URL, and group are stored as WordPress options (never hardcoded in the snippet)
Group email requirement: for agent email replies to reach customers, the ticket group must have an outbound email address assigned (Zammad Admin → Groups →
opshell.dev→ email addresssupport@opshell.dev). Replies sent as email articles (the Reply composer) go out assupport@opshell.dev; internal notes are never emailed.
One-Time Setup (Rails console on GSM16)
The following were executed once to prepare Zammad (via sudo docker exec zammad-zammad-railsserver-1 /opt/zammad/bin/rails r '...'):
- Create the ticket group:
u = User.find_by(login: "donnyaw@gmail.com")g = Group.new(name: "opshell.dev", active: true)g.created_by_id = u.idg.updated_by_id = u.idg.save
- Grant the agent access to the group:
u = User.find_by(login: "donnyaw@gmail.com")u.groups << Group.find_by(name: "opshell.dev")u.save
- Create the API token (persistent,
ticket.agentpermission only):t = Token.create!(action: "api",persistent: true,user_id: User.find_by(login: "donnyaw@gmail.com").id,name: "opshell-contact-form",preferences: { permission: { "ticket.agent" => true } })puts t.token # raw token to store in WordPress - Assign the outbound email address to the group (enables agent email replies):
g = Group.find_by(name: "opshell.dev")g.email_address_id = EmailAddress.find_by(email: "support@opshell.dev").idg.updated_by_id = User.find_by(login: "donnyaw@gmail.com").idg.save
Configuration
| Setting | Option Name | Default |
|---|---|---|
| API base URL | opshell_zammad_url | https://zammad.id86.net |
| API token | opshell_zammad_api_token | — |
| Ticket group | opshell_zammad_group | opshell.dev |
Set via WP-CLI:
wp option add opshell_zammad_url 'https://zammad.id86.net'
wp option add opshell_zammad_api_token '<TOKEN>'
wp option add opshell_zammad_group 'opshell.dev'
Ticket Creation
opshell_send_contact_zammad() posts to POST {url}/api/v1/tickets:
wp_remote_post(
untrailingslashit( $url ) . "/api/v1/tickets",
array(
"headers" => array(
"Authorization" => "Token token=" . $api_token,
"Content-Type" => "application/json",
),
"body" => wp_json_encode( array(
"title" => "New contact: " . $subject,
"group" => $group,
"customer_id" => "guess:" . $email,
"article" => array(
"subject" => "Contact form: " . $subject,
"body" => $body, // name, email, ip, message, Reoon results
"type" => "web",
"internal" => false,
),
) ),
)
);
Key behaviors:
customer_id: "guess:{email}"— looks up the user by email or auto-creates a customer (no extra API call)- The article body includes the full submission: name, email, IP, message, and Reoon verification results
- Returns
trueon HTTP 200/201; ticket creation failure is non-blocking (does not affect the visitor's success message) - Success requires
opshell_zammad_urlandopshell_zammad_api_tokento be set
Snippet Code (WPCodeBox2, ID 1)
The function lives in the "Opshell Contact Form Shortcode" snippet on GC-SG-M8:
function opshell_send_contact_zammad( $name, $email, $subject, $message, $verification ) {
$url = get_option( "opshell_zammad_url", "" );
$api_token = get_option( "opshell_zammad_api_token", "" );
$group = get_option( "opshell_zammad_group", "opshell.dev" );
if ( ! $url || ! $api_token ) {
return false;
}
// ... build article body (name, email, ip, message, Reoon results) ...
$response = wp_remote_post(
untrailingslashit( $url ) . "/api/v1/tickets",
array(
"timeout" => 25,
"headers" => array(
"Authorization" => "Token token=" . $api_token,
"Content-Type" => "application/json",
),
"body" => wp_json_encode( array(
"title" => "New contact: " . $subject,
"group" => $group,
"customer_id" => "guess:" . $email,
"article" => array(
"subject" => "Contact form: " . $subject,
"body" => $body,
"type" => "web",
"internal" => false,
),
) ),
)
);
if ( is_wp_error( $response ) ) {
return false;
}
$code = wp_remote_retrieve_response_code( $response );
return 201 === $code || 200 === $code;
}
It is invoked in opshell_render_contact_form() only after the Reoon is_safe_to_send gate passes and the CSV row is written:
$csv_file = opshell_save_contact_submission_csv( $name, $email, $subject, $message );
if ( ! $csv_file ) {
$error = "Could not save your message. Please try again later.";
} else {
opshell_send_contact_zammad( $name, $email, $subject, $message, $verification );
opshell_send_contact_telegram( $name, $email, $subject, $message, $verification );
opshell_send_contact_notification_email( $name, $email, $subject, $message, $verification );
$sent = true;
}
End-to-End Workflow
Agent Replies (Outbound Email)
- To reply to a customer, agents must use the email reply composer (creates an
email-type article) — internal notes are never emailed. - Replies are sent as
support@opshell.dev(the group's assigned email address) via Zammad's SMTP channel (mail.opshell.dev:587). - The customer's email comes from the ticket's customer record (auto-created from the form email via
guess:).
Operations & Troubleshooting
Verify a ticket exists
curl -H "Authorization: Token token=<TOKEN>" \
"https://zammad.id86.net/api/v1/tickets/search?query=<email-or-subject>"
Verify outbound SMTP from Zammad
sudo docker exec zammad-zammad-railsserver-1 /opt/zammad/bin/rails r '
Mail.new(to: "you@example.com", from: "support@opshell.dev",
subject: "test", body: "test").deliver
'
Common issues
| Symptom | Cause / Fix |
|---|---|
| No ticket created | Reoon verification failed (by design — only verified emails reach Zammad); or opshell_zammad_url/token not set |
| API error "No lookup value for group" | Group name mismatch, or token's agent lacks access to the group |
| Agent reply never emailed | Reply was a note (not an email article); or the group has no outbound email address assigned |
| SMTP auth failed | Outbound password in Zammad channel settings differs from the mail server's |
Endpoint Performance
The default opshell_zammad_url uses the public Cloudflare-tunneled URL (~166ms round-trip, encrypted). For lower latency, the direct internal endpoint on GSM16 (http://195.85.19.185:8080, ~33ms) can be used via the option — but it sends the token over plain HTTP and should only be used on a trusted network.
Security
- The API token is a secret — stored only as a WordPress option, never in the repo or docs
- Ticket creation runs only for Reoon-verified submissions
- The token has
ticket.agentpermission only (no admin access)