Skip to main content

zammad_api/
models.rs

1//! Strongly typed Zammad entities.
2//!
3//! Every record type carries an `extra` field that captures custom attributes
4//! Zammad returns but that aren't covered by the typed schema. Use it to
5//! read or write Object-Manager custom fields without having to redefine the
6//! whole model.
7//!
8//! For tickets the crate exposes two shapes: the compact [`Ticket`] (default)
9//! and [`TicketExpanded`], which carries human-readable name strings
10//! Zammad adds when `expand=true` is sent. Select between them at call site
11//! via [`crate::api::TicketsApi::list`] vs [`crate::api::TicketsApi::list_expanded`].
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17/// A Zammad ticket (compact form - IDs only).
18#[derive(Debug, Clone, Deserialize, Serialize)]
19pub struct Ticket {
20    /// Numeric ticket ID.
21    pub id: u64,
22    /// Customer-facing ticket number (e.g. `"100123"`).
23    #[serde(default)]
24    pub number: Option<String>,
25    /// Ticket subject / title.
26    #[serde(default)]
27    pub title: Option<String>,
28    /// ID of the assigned group (queue).
29    #[serde(default)]
30    pub group_id: Option<u64>,
31    /// ID of the current state (`new`, `open`, `closed`, ...).
32    #[serde(default)]
33    pub state_id: Option<u64>,
34    /// ID of the assigned priority.
35    #[serde(default)]
36    pub priority_id: Option<u64>,
37    /// ID of the customer this ticket belongs to.
38    #[serde(default)]
39    pub customer_id: Option<u64>,
40    /// ID of the agent currently owning the ticket.
41    #[serde(default)]
42    pub owner_id: Option<u64>,
43    /// ID of the customer's organization, if any.
44    #[serde(default)]
45    pub organization_id: Option<u64>,
46    /// Free-form internal note attached to the ticket.
47    #[serde(default)]
48    pub note: Option<String>,
49    /// When the ticket was created.
50    #[serde(default)]
51    pub created_at: Option<DateTime<Utc>>,
52    /// When the ticket was last updated.
53    #[serde(default)]
54    pub updated_at: Option<DateTime<Utc>>,
55    /// Any attribute Zammad returned that isn't modelled above (typically
56    /// Object-Manager custom fields).
57    #[serde(flatten)]
58    pub extra: Map<String, Value>,
59}
60
61/// A Zammad ticket returned with `expand=true`.
62///
63/// In addition to the numeric `*_id` fields carried by [`Ticket`], the
64/// server adds human-readable name strings for group, state, priority,
65/// customer, owner and organization.
66#[derive(Debug, Clone, Deserialize, Serialize)]
67pub struct TicketExpanded {
68    /// Numeric ticket ID.
69    pub id: u64,
70    /// Customer-facing ticket number.
71    #[serde(default)]
72    pub number: Option<String>,
73    /// Ticket subject / title.
74    #[serde(default)]
75    pub title: Option<String>,
76    /// ID of the assigned group.
77    #[serde(default)]
78    pub group_id: Option<u64>,
79    /// Name of the assigned group, e.g. `"Users"`.
80    #[serde(default)]
81    pub group: Option<String>,
82    /// ID of the current state.
83    #[serde(default)]
84    pub state_id: Option<u64>,
85    /// Name of the current state, e.g. `"open"`.
86    #[serde(default)]
87    pub state: Option<String>,
88    /// ID of the assigned priority.
89    #[serde(default)]
90    pub priority_id: Option<u64>,
91    /// Name of the assigned priority, e.g. `"2 normal"`.
92    #[serde(default)]
93    pub priority: Option<String>,
94    /// ID of the customer.
95    #[serde(default)]
96    pub customer_id: Option<u64>,
97    /// Login / email of the customer.
98    #[serde(default)]
99    pub customer: Option<String>,
100    /// ID of the owning agent.
101    #[serde(default)]
102    pub owner_id: Option<u64>,
103    /// Login / email of the owning agent.
104    #[serde(default)]
105    pub owner: Option<String>,
106    /// ID of the customer's organization.
107    #[serde(default)]
108    pub organization_id: Option<u64>,
109    /// Name of the customer's organization.
110    #[serde(default)]
111    pub organization: Option<String>,
112    /// Free-form internal note.
113    #[serde(default)]
114    pub note: Option<String>,
115    /// When the ticket was created.
116    #[serde(default)]
117    pub created_at: Option<DateTime<Utc>>,
118    /// When the ticket was last updated.
119    #[serde(default)]
120    pub updated_at: Option<DateTime<Utc>>,
121    /// Any attribute Zammad returned that isn't modelled above.
122    #[serde(flatten)]
123    pub extra: Map<String, Value>,
124}
125
126/// A Zammad user (agent or customer).
127#[derive(Debug, Clone, Deserialize, Serialize)]
128pub struct User {
129    /// Numeric user ID.
130    pub id: u64,
131    /// Login (often the email).
132    #[serde(default)]
133    pub login: Option<String>,
134    /// Given name.
135    #[serde(default)]
136    pub firstname: Option<String>,
137    /// Family name.
138    #[serde(default)]
139    pub lastname: Option<String>,
140    /// Primary email.
141    #[serde(default)]
142    pub email: Option<String>,
143    /// Personal website URL.
144    #[serde(default)]
145    pub web: Option<String>,
146    /// Landline phone.
147    #[serde(default)]
148    pub phone: Option<String>,
149    /// Mobile phone.
150    #[serde(default)]
151    pub mobile: Option<String>,
152    /// Fax number.
153    #[serde(default)]
154    pub fax: Option<String>,
155    /// Free-form internal note.
156    #[serde(default)]
157    pub note: Option<String>,
158    /// ID of the primary organization, if any.
159    #[serde(default)]
160    pub organization_id: Option<u64>,
161    /// Whether the account is active.
162    #[serde(default)]
163    pub active: Option<bool>,
164    /// Whether the email is verified.
165    #[serde(default)]
166    pub verified: Option<bool>,
167    /// When the user was created.
168    #[serde(default)]
169    pub created_at: Option<DateTime<Utc>>,
170    /// When the user was last updated.
171    #[serde(default)]
172    pub updated_at: Option<DateTime<Utc>>,
173    /// Any attribute not modelled above (incl. custom fields).
174    #[serde(flatten)]
175    pub extra: Map<String, Value>,
176}
177
178/// Payload for `POST /api/v1/users`.
179#[derive(Debug, Clone, Default, Serialize)]
180pub struct NewUser {
181    /// Given name.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub firstname: Option<String>,
184    /// Family name.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub lastname: Option<String>,
187    /// Primary email.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub email: Option<String>,
190    /// Login (defaults server-side to the email).
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub login: Option<String>,
193    /// Landline phone.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub phone: Option<String>,
196    /// Mobile phone.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub mobile: Option<String>,
199    /// Organization name (Zammad resolves it to an ID).
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub organization: Option<String>,
202    /// Role names, e.g. `["Customer"]` or `["Agent"]`.
203    #[serde(skip_serializing_if = "Vec::is_empty", default)]
204    pub roles: Vec<String>,
205    /// Additional fields (custom attributes, etc.).
206    #[serde(flatten)]
207    pub extra: Map<String, Value>,
208}
209
210impl NewUser {
211    /// Convenience constructor seeded with the primary email.
212    pub fn with_email(email: impl Into<String>) -> Self {
213        Self {
214            email: Some(email.into()),
215            ..Self::default()
216        }
217    }
218
219    /// Set the given name.
220    pub fn firstname(mut self, s: impl Into<String>) -> Self {
221        self.firstname = Some(s.into());
222        self
223    }
224    /// Set the family name.
225    pub fn lastname(mut self, s: impl Into<String>) -> Self {
226        self.lastname = Some(s.into());
227        self
228    }
229    /// Set the phone.
230    pub fn phone(mut self, s: impl Into<String>) -> Self {
231        self.phone = Some(s.into());
232        self
233    }
234    /// Append a role.
235    pub fn role(mut self, s: impl Into<String>) -> Self {
236        self.roles.push(s.into());
237        self
238    }
239    /// Set an arbitrary custom field on the new user.
240    pub fn field(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
241        self.extra.insert(key.into(), value.into());
242        self
243    }
244}
245
246/// A ticket article (note, email, phone log, ...).
247#[derive(Debug, Clone, Deserialize, Serialize)]
248pub struct Article {
249    /// Numeric article ID.
250    pub id: u64,
251    /// ID of the parent ticket.
252    #[serde(default)]
253    pub ticket_id: Option<u64>,
254    /// Subject line.
255    #[serde(default)]
256    pub subject: Option<String>,
257    /// Article body (plain text or HTML depending on `content_type`).
258    #[serde(default)]
259    pub body: Option<String>,
260    /// Sender address (free-form string Zammad chose).
261    #[serde(default)]
262    pub from: Option<String>,
263    /// Recipient address.
264    #[serde(default)]
265    pub to: Option<String>,
266    /// CC recipients.
267    #[serde(default)]
268    pub cc: Option<String>,
269    /// Reply-To header.
270    #[serde(default)]
271    pub reply_to: Option<String>,
272    /// MIME content type (`text/plain`, `text/html`).
273    #[serde(default)]
274    pub content_type: Option<String>,
275    /// Article type (`note`, `email`, `phone`, ...).
276    #[serde(rename = "type", default)]
277    pub article_type: Option<String>,
278    /// Sender role (`Agent`, `Customer`, `System`).
279    #[serde(default)]
280    pub sender: Option<String>,
281    /// Whether the article is hidden from customers.
282    #[serde(default)]
283    pub internal: Option<bool>,
284    /// When the article was created.
285    #[serde(default)]
286    pub created_at: Option<DateTime<Utc>>,
287    /// When the article was last updated.
288    #[serde(default)]
289    pub updated_at: Option<DateTime<Utc>>,
290    /// File attachments on the article.
291    #[serde(default)]
292    pub attachments: Vec<Attachment>,
293    /// Any attribute not modelled above.
294    #[serde(flatten)]
295    pub extra: Map<String, Value>,
296}
297
298/// Payload for `POST /api/v1/ticket_articles` and inline `article` on ticket creation.
299#[derive(Debug, Clone, Default, Serialize)]
300pub struct NewArticle {
301    /// Subject line.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub subject: Option<String>,
304    /// Article body.
305    pub body: String,
306    /// Article type, e.g. `note`, `email`, `phone`, `web`, `sms`.
307    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
308    pub article_type: Option<String>,
309    /// Whether the article should be hidden from customers.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub internal: Option<bool>,
312    /// MIME content type - `text/plain` (default server-side) or `text/html`.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub content_type: Option<String>,
315    /// Sender role override (`Agent`, `Customer`, `System`).
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub sender: Option<String>,
318    /// From address (mail-type articles).
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub from: Option<String>,
321    /// To address (mail-type articles).
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub to: Option<String>,
324    /// CC addresses.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub cc: Option<String>,
327    /// Inline attachments.
328    #[serde(skip_serializing_if = "Vec::is_empty", default)]
329    pub attachments: Vec<NewAttachment>,
330    /// Parent ticket - filled in automatically by
331    /// [`crate::api::ArticlesApi::create`], not needed when used inline.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub ticket_id: Option<u64>,
334}
335
336impl NewArticle {
337    /// Build with the given body. Without an explicit `article_type` the
338    /// server defaults to a note.
339    pub fn new(body: impl Into<String>) -> Self {
340        Self {
341            body: body.into(),
342            ..Self::default()
343        }
344    }
345
346    /// Set the subject line.
347    pub fn subject(mut self, s: impl Into<String>) -> Self {
348        self.subject = Some(s.into());
349        self
350    }
351    /// Set the article type (`note`, `email`, `phone`, `web`, `sms`, ...).
352    pub fn article_type(mut self, s: impl Into<String>) -> Self {
353        self.article_type = Some(s.into());
354        self
355    }
356    /// Set the MIME content type (`text/plain` or `text/html`).
357    pub fn content_type(mut self, s: impl Into<String>) -> Self {
358        self.content_type = Some(s.into());
359        self
360    }
361    /// Mark the article as internal (hidden from customers).
362    pub fn internal(mut self, b: bool) -> Self {
363        self.internal = Some(b);
364        self
365    }
366    /// Override the sender role.
367    pub fn sender(mut self, s: impl Into<String>) -> Self {
368        self.sender = Some(s.into());
369        self
370    }
371    /// Set the From address.
372    pub fn from(mut self, s: impl Into<String>) -> Self {
373        self.from = Some(s.into());
374        self
375    }
376    /// Set the To address.
377    pub fn to(mut self, s: impl Into<String>) -> Self {
378        self.to = Some(s.into());
379        self
380    }
381    /// Set the CC addresses.
382    pub fn cc(mut self, s: impl Into<String>) -> Self {
383        self.cc = Some(s.into());
384        self
385    }
386    /// Append an attachment.
387    pub fn attach(mut self, a: NewAttachment) -> Self {
388        self.attachments.push(a);
389        self
390    }
391}
392
393/// Attachment metadata returned by Zammad.
394#[derive(Debug, Clone, Deserialize, Serialize)]
395pub struct Attachment {
396    /// Numeric attachment ID.
397    pub id: u64,
398    /// File name as uploaded.
399    #[serde(default)]
400    pub filename: Option<String>,
401    /// File size - Zammad reports this as a stringified integer.
402    #[serde(default)]
403    pub size: Option<String>,
404    /// Server-side preferences blob (content type, content disposition, ...).
405    #[serde(default)]
406    pub preferences: Option<Value>,
407}
408
409/// Attachment payload for outbound articles. `data` is base64-encoded.
410#[derive(Debug, Clone, Serialize)]
411pub struct NewAttachment {
412    /// File name to show in Zammad.
413    pub filename: String,
414    /// Base64-encoded file content.
415    pub data: String,
416    /// MIME type, e.g. `text/plain`, `application/pdf`.
417    #[serde(rename = "mime-type")]
418    pub mime_type: String,
419}
420
421impl NewAttachment {
422    /// Build from raw bytes - performs base64 encoding for you.
423    pub fn from_bytes(
424        filename: impl Into<String>,
425        mime_type: impl Into<String>,
426        bytes: &[u8],
427    ) -> Self {
428        use base64::Engine;
429        Self {
430            filename: filename.into(),
431            mime_type: mime_type.into(),
432            data: base64::engine::general_purpose::STANDARD.encode(bytes),
433        }
434    }
435}
436
437/// A Zammad organization.
438#[derive(Debug, Clone, Deserialize, Serialize)]
439pub struct Organization {
440    /// Numeric organization ID.
441    pub id: u64,
442    /// Display name.
443    #[serde(default)]
444    pub name: Option<String>,
445    /// Whether the organization is shared with all agents.
446    #[serde(default)]
447    pub shared: Option<bool>,
448    /// Email domain used for automatic assignment.
449    #[serde(default)]
450    pub domain: Option<String>,
451    /// Whether new users with `@domain` emails get auto-assigned.
452    #[serde(default)]
453    pub domain_assignment: Option<bool>,
454    /// Whether the organization is active.
455    #[serde(default)]
456    pub active: Option<bool>,
457    /// Free-form internal note.
458    #[serde(default)]
459    pub note: Option<String>,
460    /// When the organization was created.
461    #[serde(default)]
462    pub created_at: Option<DateTime<Utc>>,
463    /// When the organization was last updated.
464    #[serde(default)]
465    pub updated_at: Option<DateTime<Utc>>,
466    /// Any attribute not modelled above (incl. custom fields).
467    #[serde(flatten)]
468    pub extra: Map<String, Value>,
469}
470
471/// Payload for `POST /api/v1/organizations`.
472#[derive(Debug, Clone, Default, Serialize)]
473pub struct NewOrganization {
474    /// Display name (required).
475    pub name: String,
476    /// Email domain used for automatic assignment.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub domain: Option<String>,
479    /// Whether to share the organization with all agents.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub shared: Option<bool>,
482    /// Free-form internal note.
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub note: Option<String>,
485    /// Additional fields (custom attributes, etc.).
486    #[serde(flatten)]
487    pub extra: Map<String, Value>,
488}
489
490impl NewOrganization {
491    /// Build with the given name.
492    pub fn new(name: impl Into<String>) -> Self {
493        Self {
494            name: name.into(),
495            ..Self::default()
496        }
497    }
498}
499
500/// A Zammad group (queue).
501#[derive(Debug, Clone, Deserialize, Serialize)]
502pub struct Group {
503    /// Numeric group ID.
504    pub id: u64,
505    /// Display name.
506    #[serde(default)]
507    pub name: Option<String>,
508    /// ID of the signature attached to outbound mail from this group.
509    #[serde(default)]
510    pub signature_id: Option<u64>,
511    /// ID of the email-address record this group sends from.
512    #[serde(default)]
513    pub email_address_id: Option<u64>,
514    /// Follow-up policy, e.g. `"yes"` / `"new_ticket"`.
515    #[serde(default)]
516    pub follow_up_possible: Option<String>,
517    /// Whether follow-ups are reassigned to the previous owner.
518    #[serde(default)]
519    pub follow_up_assignment: Option<bool>,
520    /// Whether the group is active.
521    #[serde(default)]
522    pub active: Option<bool>,
523    /// Free-form note.
524    #[serde(default)]
525    pub note: Option<String>,
526    /// Any attribute not modelled above.
527    #[serde(flatten)]
528    pub extra: Map<String, Value>,
529}
530
531/// A configurable ticket state.
532#[derive(Debug, Clone, Deserialize, Serialize)]
533pub struct TicketState {
534    /// Numeric state ID.
535    pub id: u64,
536    /// Display name (`new`, `open`, `closed`, ...).
537    #[serde(default)]
538    pub name: Option<String>,
539    /// ID of the underlying state type.
540    #[serde(default)]
541    pub state_type_id: Option<u64>,
542    /// Whether tickets in this state ignore escalation timers.
543    #[serde(default)]
544    pub ignore_escalation: Option<bool>,
545    /// Whether this is the default state on ticket creation.
546    #[serde(default)]
547    pub default_create: Option<bool>,
548    /// Whether this is the default state on customer follow-up.
549    #[serde(default)]
550    pub default_follow_up: Option<bool>,
551    /// Whether the state is active.
552    #[serde(default)]
553    pub active: Option<bool>,
554    /// Free-form note.
555    #[serde(default)]
556    pub note: Option<String>,
557}
558
559/// A configurable ticket priority.
560#[derive(Debug, Clone, Deserialize, Serialize)]
561pub struct TicketPriority {
562    /// Numeric priority ID.
563    pub id: u64,
564    /// Display name (`1 low`, `2 normal`, `3 high`, ...).
565    #[serde(default)]
566    pub name: Option<String>,
567    /// Whether this is the default priority on ticket creation.
568    #[serde(default)]
569    pub default_create: Option<bool>,
570    /// Icon class shown in the UI.
571    #[serde(default)]
572    pub ui_icon: Option<String>,
573    /// Color used in the UI.
574    #[serde(default)]
575    pub ui_color: Option<String>,
576    /// Whether the priority is active.
577    #[serde(default)]
578    pub active: Option<bool>,
579    /// Free-form note.
580    #[serde(default)]
581    pub note: Option<String>,
582}
583
584#[cfg(test)]
585mod tests {
586    use super::NewAttachment;
587
588    #[test]
589    fn attachment_encodes_bytes_as_base64() {
590        let a = NewAttachment::from_bytes("hi.txt", "text/plain", b"foobar");
591        assert_eq!(a.data, "Zm9vYmFy");
592    }
593}