taint enforce: file access backstops
[users/jgh/exim.git] / src / exim_monitor / em_queue.c
1 /*************************************************
2 *                 Exim Monitor                   *
3 *************************************************/
4
5 /* Copyright (c) University of Cambridge 1995 - 2018 */
6 /* See the file NOTICE for conditions of use and distribution. */
7
8
9 #include "em_hdr.h"
10
11
12 /* This module contains functions to do with scanning exim's
13 queue and displaying the data therefrom. */
14
15
16 /* If we are anonymizing for screen shots, define a function to anonymize
17 addresses. Otherwise, define a macro that does nothing. */
18
19 #ifdef ANONYMIZE
20 static uschar *anon(uschar *s)
21 {
22 static uschar anon_result[256];
23 uschar *ss = anon_result;
24 for (; *s != 0; s++) *ss++ = (*s == '@' || *s == '.')? *s : 'x';
25 *ss = 0;
26 return anon_result;
27 }
28 #else
29 #define anon(x) x
30 #endif
31
32
33 /*************************************************
34 *                 Static variables               *
35 *************************************************/
36
37 static int queue_total = 0;   /* number of items in queue */
38
39 /* Table for turning base-62 numbers into binary */
40
41 static uschar tab62[] =
42           {0,1,2,3,4,5,6,7,8,9,0,0,0,0,0,0,     /* 0-9 */
43            0,10,11,12,13,14,15,16,17,18,19,20,  /* A-K */
44           21,22,23,24,25,26,27,28,29,30,31,32,  /* L-W */
45           33,34,35, 0, 0, 0, 0, 0,              /* X-Z */
46            0,36,37,38,39,40,41,42,43,44,45,46,  /* a-k */
47           47,48,49,50,51,52,53,54,55,56,57,58,  /* l-w */
48           59,60,61};                            /* x-z */
49
50 /* Index for quickly finding things in the ordered queue. */
51
52 static queue_item *queue_index[queue_index_size];
53
54
55
56 /*************************************************
57 *         Find/Create/Delete a destination       *
58 *************************************************/
59
60 /* If the action is dest_noop, then just return item or NULL;
61 if it is dest_add, then add if not present, and return item;
62 if it is dest_remove, remove if present and return NULL. The
63 address is lowercased to start with, unless it begins with
64 "*", which it does for error messages. */
65
66 dest_item *
67 find_dest(queue_item *q, uschar *name, int action, BOOL caseless)
68 {
69 dest_item *dd;
70 dest_item **d = &(q->destinations);
71
72 while (*d != NULL)
73   {
74   if ((caseless? strcmpic(name,(*d)->address) : Ustrcmp(name,(*d)->address))
75         == 0)
76     {
77     dest_item *ddd;
78
79     if (action != dest_remove) return *d;
80     dd = *d;
81     *d = dd->next;
82     store_free(dd);
83
84     /* Unset any parent pointers that were to this address */
85
86     for (ddd = q->destinations; ddd != NULL; ddd = ddd->next)
87       {
88       if (ddd->parent == dd) ddd->parent = NULL;
89       }
90
91     return NULL;
92     }
93   d = &((*d)->next);
94   }
95
96 if (action != dest_add) return NULL;
97
98 dd = (dest_item *)store_malloc(sizeof(dest_item) + Ustrlen(name));
99 Ustrcpy(dd->address, name);
100 dd->next = NULL;
101 dd->parent = NULL;
102 *d = dd;
103 return dd;
104 }
105
106
107
108 /*************************************************
109 *            Clean up a dead queue item          *
110 *************************************************/
111
112 static void
113 clean_up(queue_item *p)
114 {
115 dest_item *dd = p->destinations;
116 while (dd != NULL)
117   {
118   dest_item *next = dd->next;
119   store_free(dd);
120   dd = next;
121   }
122 if (p->sender != NULL) store_free(p->sender);
123 store_free(p);
124 }
125
126
127 /*************************************************
128 *         Set up an ACL variable                 *
129 *************************************************/
130
131 /* The spool_read_header() function calls acl_var_create() when it reads in an
132 ACL variable. We know that in this case, the variable will be new, not re-used,
133 so this is a cut-down version, to save including the whole acl.c module (which
134 would need conditional compilation to cut most of it out). */
135
136 tree_node *
137 acl_var_create(uschar *name)
138 {
139 tree_node *node, **root;
140 root = (name[0] == 'c')? &acl_var_c : &acl_var_m;
141 node = store_get(sizeof(tree_node) + Ustrlen(name), FALSE);
142 Ustrcpy(node->name, name);
143 node->data.ptr = NULL;
144 (void)tree_insertnode(root, node);
145 return node;
146 }
147
148
149
150 /*************************************************
151 *             Set up new queue item              *
152 *************************************************/
153
154 static queue_item *
155 set_up(uschar *name, int dir_char)
156 {
157 int i, rc, save_errno;
158 struct stat statdata;
159 rmark reset_point;
160 uschar *p;
161 queue_item *q = (queue_item *)store_malloc(sizeof(queue_item));
162 uschar buffer[256];
163
164 /* Initialize the block */
165
166 q->next = q->prev = NULL;
167 q->destinations = NULL;
168 Ustrncpy(q->name, name, sizeof(q->name));
169 q->seen = TRUE;
170 q->frozen = FALSE;
171 q->dir_char = dir_char;
172 q->sender = NULL;
173 q->size = 0;
174
175 /* Read the header file from the spool; if there is a failure it might mean
176 inaccessibility as a result of protections. A successful read will have caused
177 sender_address to get set and the recipients fields to be initialized. If
178 there's a format error in the headers, we can still display info from the
179 envelope.
180
181 Before reading the header remember the position in the dynamic store so that
182 we can recover the store into which the header is read. All data read by
183 spool_read_header that is to be preserved is copied into malloc store. */
184
185 reset_point = store_mark();
186 message_size = 0;
187 message_subdir[0] = dir_char;
188 sprintf(CS buffer, "%s-H", name);
189 rc =  spool_read_header(buffer, FALSE, TRUE);
190 save_errno = errno;
191
192 /* If we failed to read the envelope, compute the input time by
193 interpreting the id as a base-62 number. */
194
195 if (rc != spool_read_OK && rc != spool_read_hdrerror)
196   {
197   int t = 0;
198   for (i = 0; i < 6; i++) t = t * 62 + tab62[name[i] - '0'];
199   q->update_time = q->input_time = t;
200   }
201
202 /* Envelope read; get input time and remove qualify_domain from sender address,
203 if it's there. */
204
205 else
206   {
207   q->update_time = q->input_time = received_time.tv_sec;
208   if ((p = strstric(sender_address+1, qualify_domain, FALSE)) != NULL &&
209     *(--p) == '@') *p = 0;
210   }
211
212 /* If we didn't read the whole header successfully, generate an error
213 message. If the envelope was read, this appears as a first recipient;
214 otherwise it sets set up in the sender field. */
215
216 if (rc != spool_read_OK)
217   {
218   uschar *msg;
219
220   if (save_errno == ERRNO_SPOOLFORMAT)
221     {
222     struct stat statbuf;
223     sprintf(CS big_buffer, "%s/input/%s", spool_directory, buffer);
224     if (Ustat(big_buffer, &statbuf) == 0)
225       msg = string_sprintf("*** Format error in spool file: size = " OFF_T_FMT " ***",
226         statbuf.st_size);
227     else msg = US"*** Format error in spool file ***";
228     }
229   else msg = US"*** Cannot read spool file ***";
230
231   if (rc == spool_read_hdrerror)
232     {
233     (void)find_dest(q, msg, dest_add, FALSE);
234     }
235   else
236     {
237     f.deliver_freeze = FALSE;
238     sender_address = msg;
239     recipients_count = 0;
240     }
241   }
242
243 /* Now set up the remaining data. */
244
245 q->frozen = f.deliver_freeze;
246
247 if (f.sender_set_untrusted)
248   {
249   if (sender_address[0] == 0)
250     {
251     q->sender = store_malloc(Ustrlen(originator_login) + 6);
252     sprintf(CS q->sender, "<> (%s)", originator_login);
253     }
254   else
255     {
256     q->sender = store_malloc(Ustrlen(sender_address) +
257       Ustrlen(originator_login) + 4);
258     sprintf(CS q->sender, "%s (%s)", sender_address, originator_login);
259     }
260   }
261 else
262   {
263   q->sender = store_malloc(Ustrlen(sender_address) + 1);
264   Ustrcpy(q->sender, sender_address);
265   }
266
267 sender_address = NULL;
268
269 snprintf(CS buffer, sizeof(buffer), "%s/input/%s/%s/%s-D",
270   spool_directory, queue_name, message_subdir, name);
271 if (Ustat(buffer, &statdata) == 0)
272   q->size = message_size + statdata.st_size - SPOOL_DATA_START_OFFSET + 1;
273
274 /* Scan and process the recipients list, skipping any that have already
275 been delivered, and removing visible names. */
276
277 if (recipients_list != NULL)
278   for (i = 0; i < recipients_count; i++)
279     {
280     uschar *r = recipients_list[i].address;
281     if (tree_search(tree_nonrecipients, r) == NULL)
282       {
283       if ((p = strstric(r+1, qualify_domain, FALSE)) != NULL &&
284         *(--p) == '@') *p = 0;
285       (void)find_dest(q, r, dest_add, FALSE);
286       }
287     }
288
289 /* Recover the dynamic store used by spool_read_header(). */
290
291 store_reset(reset_point);
292 return q;
293 }
294
295
296
297 /*************************************************
298 *             Find/Create a queue item           *
299 *************************************************/
300
301 /* The queue is kept as a doubly-linked list, sorted by name. However,
302 to speed up searches, an index into the list is used. This is maintained
303 by the scan_spool_input function when it goes down the list throwing
304 out entries that are no longer needed. When the action is "add" and
305 we don't need to add, mark the found item as seen. */
306
307
308 #ifdef never
309 static void debug_queue(void)
310 {
311 int i;
312 int count = 0;
313 queue_item *p;
314 printf("\nqueue_total=%d\n", queue_total);
315
316 for (i = 0; i < queue_index_size; i++)
317   printf("index %d = %d %s\n", i, (int)(queue_index[i]),
318     (queue_index[i])->name);
319
320 printf("Queue is:\n");
321 p = queue_index[0];
322 while (p != NULL)
323   {
324   count++;
325   for (i = 0; i < queue_index_size; i++)
326     {
327     if (queue_index[i] == p) printf("count=%d index=%d\n", count, (int)p);
328     }
329   printf("%d %d %d %s\n", (int)p, (int)p->next, (int)p->prev, p->name);
330   p = p->next;
331   }
332 }
333 #endif
334
335
336
337 queue_item *
338 find_queue(uschar *name, int action, int dir_char)
339 {
340 int first = 0;
341 int last = queue_index_size - 1;
342 int middle = (first + last)/2;
343 queue_item *p, *q, *qq;
344
345 /* Handle the empty queue as a special case. */
346
347 if (queue_total == 0)
348   {
349   if (action != queue_add) return NULL;
350   if ((qq = set_up(name, dir_char)) != NULL)
351     {
352     int i;
353     for (i = 0; i < queue_index_size; i++) queue_index[i] = qq;
354     queue_total++;
355     return qq;
356     }
357   return NULL;
358   }
359
360 /* Also handle insertion at the start or end of the queue
361 as special cases. */
362
363 if (Ustrcmp(name, (queue_index[0])->name) < 0)
364   {
365   if (action != queue_add) return NULL;
366   if ((qq = set_up(name, dir_char)) != NULL)
367     {
368     qq->next = queue_index[0];
369     (queue_index[0])->prev = qq;
370     queue_index[0] = qq;
371     queue_total++;
372     return qq;
373     }
374   return NULL;
375   }
376
377 if (Ustrcmp(name, (queue_index[queue_index_size-1])->name) > 0)
378   {
379   if (action != queue_add) return NULL;
380   if ((qq = set_up(name, dir_char)) != NULL)
381     {
382     qq->prev = queue_index[queue_index_size-1];
383     (queue_index[queue_index_size-1])->next = qq;
384     queue_index[queue_index_size-1] = qq;
385     queue_total++;
386     return qq;
387     }
388   return NULL;
389   }
390
391 /* Use binary chopping on the index to get a range of the queue to search
392 when the name is somewhere in the middle, if present. */
393
394 while (middle > first)
395   {
396   if (Ustrcmp(name, (queue_index[middle])->name) >= 0) first = middle;
397     else last = middle;
398   middle = (first + last)/2;
399   }
400
401 /* Now search down the part of the queue in which the item must
402 lie if it exists. Both end points are inclusive - though in fact
403 the bottom one can only be = if it is the original bottom. */
404
405 p = queue_index[first];
406 q = queue_index[last];
407
408 for (;;)
409   {
410   int c = Ustrcmp(name, p->name);
411
412   /* Already on queue; mark seen if required. */
413
414   if (c == 0)
415     {
416     if (action == queue_add) p->seen = TRUE;
417     return p;
418     }
419
420   /* Not on the queue; add an entry if required. Note that set-up might
421   fail (the file might vanish under our feet). Note also that we know
422   there is always a previous item to p because the end points are
423   inclusive. */
424
425   else if (c < 0)
426     {
427     if (action == queue_add)
428       {
429       if ((qq = set_up(name, dir_char)) != NULL)
430         {
431         qq->next = p;
432         qq->prev = p->prev;
433         p->prev->next = qq;
434         p->prev = qq;
435         queue_total++;
436         return qq;
437         }
438       }
439     return NULL;
440     }
441
442   /* Control should not reach here if p == q, because the name
443   is supposed to be <= the name of the bottom item. */
444
445   if (p == q) return NULL;
446
447   /* Else might be further down the queue; continue */
448
449   p = p->next;
450   }
451
452 /* Control should never reach here. */
453 }
454
455
456
457 /*************************************************
458 *        Scan the exim spool directory           *
459 *************************************************/
460
461 /* If we discover that there are subdirectories, set a flag so that the menu
462 code knows to look for them. We count the entries to set the value for the
463 queue stripchart, and set up data for the queue display window if the "full"
464 option is given. */
465
466 void scan_spool_input(int full)
467 {
468 int i;
469 int subptr;
470 int subdir_max = 1;
471 int count = 0;
472 int indexptr = 1;
473 queue_item *p;
474 struct dirent *ent;
475 DIR *dd;
476 uschar input_dir[256];
477 uschar subdirs[64];
478
479 subdirs[0] = 0;
480 stripchart_total[0] = 0;
481
482 sprintf(CS input_dir, "%s/input", spool_directory);
483 subptr = Ustrlen(input_dir);
484 input_dir[subptr+2] = 0;               /* terminator for lengthened name */
485
486 /* Loop for each spool file on the queue - searching any subdirectories that
487 may exist. When initializing eximon, every file will have to be read. To show
488 there is progress, output a dot for each one to the standard output. */
489
490 for (i = 0; i < subdir_max; i++)
491   {
492   int subdirchar = subdirs[i];      /* 0 for main directory */
493   if (subdirchar != 0)
494     {
495     input_dir[subptr] = '/';
496     input_dir[subptr+1] = subdirchar;
497     }
498
499   dd = opendir(CS input_dir);
500   if (dd == NULL) continue;
501
502   while ((ent = readdir(dd)) != NULL)
503     {
504     uschar *name = US ent->d_name;
505     int len = Ustrlen(name);
506
507     /* If we find a single alphameric sub-directory on the first
508     pass, add it to the list for subsequent scans, and remember that
509     we are dealing with a split directory. */
510
511     if (i == 0 && len == 1 && isalnum(*name))
512       {
513       subdirs[subdir_max++] = *name;
514       spool_is_split = TRUE;
515       continue;
516       }
517
518     /* Otherwise, if it is a header spool file, add it to the list */
519
520     if (len == SPOOL_NAME_LENGTH &&
521         name[SPOOL_NAME_LENGTH - 2] == '-' &&
522         name[SPOOL_NAME_LENGTH - 1] == 'H')
523       {
524       uschar basename[SPOOL_NAME_LENGTH + 1];
525       stripchart_total[0]++;
526       if (!eximon_initialized) { printf("."); fflush(stdout); }
527       Ustrcpy(basename, name);
528       basename[SPOOL_NAME_LENGTH - 2] = 0;
529       if (full) find_queue(basename, queue_add, subdirchar);
530       }
531     }
532   closedir(dd);
533   }
534
535 /* If simply counting the number, we are done; same if there are no
536 items in the in-store queue. */
537
538 if (!full || queue_total == 0) return;
539
540 /* Now scan the queue and remove any items that were not in the directory. At
541 the same time, set up the index pointers into the queue. Because we are
542 removing items, the total that we are comparing against isn't actually correct,
543 but in a long queue it won't make much difference, and in a short queue it
544 doesn't matter anyway!*/
545
546 p = queue_index[0];
547 while (p != NULL)
548   {
549   if (!p->seen)
550     {
551     queue_item *next = p->next;
552     if (p->prev == NULL) queue_index[0] = next;
553       else p->prev->next = next;
554     if (next == NULL)
555       {
556       int i;
557       queue_item *q = queue_index[queue_index_size-1];
558       for (i = queue_index_size - 1; i >= 0; i--)
559         if (queue_index[i] == q) queue_index[i] = p->prev;
560       }
561     else next->prev = p->prev;
562     clean_up(p);
563     queue_total--;
564     p = next;
565     }
566   else
567     {
568     if (++count > (queue_total * indexptr)/(queue_index_size-1))
569       {
570       queue_index[indexptr++] = p;
571       }
572     p->seen = FALSE;  /* for next time */
573     p = p->next;
574     }
575   }
576
577 /* If a lot of messages have been removed at the bottom, we may not
578 have got the index all filled in yet. Make sure all the pointers
579 are legal. */
580
581 while (indexptr < queue_index_size - 1)
582   {
583   queue_index[indexptr++] = queue_index[queue_index_size-1];
584   }
585 }
586
587
588
589
590 /*************************************************
591 *    Update the recipients list for a message    *
592 *************************************************/
593
594 /* We read the spool file only if its update time differs from last time,
595 or if there is a journal file in existence. */
596
597 /* First, a local subroutine to scan the non-recipients tree and
598 remove any of them from the address list */
599
600 static void
601 scan_tree(queue_item *p, tree_node *tn)
602 {
603 if (tn != NULL)
604   {
605   if (tn->left != NULL) scan_tree(p, tn->left);
606   if (tn->right != NULL) scan_tree(p, tn->right);
607   (void)find_dest(p, tn->name, dest_remove, FALSE);
608   }
609 }
610
611 /* The main function */
612
613 static void update_recipients(queue_item *p)
614 {
615 int i;
616 FILE *jread;
617 rmark reset_point;
618 struct stat statdata;
619 uschar buffer[1024];
620
621 message_subdir[0] = p->dir_char;
622
623 snprintf(CS buffer, sizeof(buffer), "%s/input/%s/%s/%s-J",
624   spool_directory, queue_name, message_subdir, p->name);
625
626 if (!(jread = fopen(CS buffer, "r")))
627   {
628   snprintf(CS buffer, sizeof(buffer), "%s/input/%s/%s/%s-H",
629     spool_directory, queue_name, message_subdir, p->name);
630   if (Ustat(buffer, &statdata) < 0 || p->update_time == statdata.st_mtime)
631     return;
632   }
633
634 /* Get the contents of the header file; if any problem, just give up.
635 Arrange to recover the dynamic store afterwards. */
636
637 reset_point = store_mark();
638 sprintf(CS buffer, "%s-H", p->name);
639 if (spool_read_header(buffer, FALSE, TRUE) != spool_read_OK)
640   {
641   store_reset(reset_point);
642   if (jread != NULL) fclose(jread);
643   return;
644   }
645
646 /* If there's a journal file, add its contents to the non-recipients tree */
647
648 if (jread != NULL)
649   {
650   while (Ufgets(big_buffer, big_buffer_size, jread) != NULL)
651     {
652     int n = Ustrlen(big_buffer);
653     big_buffer[n-1] = 0;
654     tree_add_nonrecipient(big_buffer);
655     }
656   fclose(jread);
657   }
658
659 /* Scan and process the recipients list, removing any that have already
660 been delivered, and removing visible names. In the nonrecipients tree,
661 domains are lower cased. */
662
663 if (recipients_list)
664   for (i = 0; i < recipients_count; i++)
665     {
666     uschar * pp;
667     uschar * r = recipients_list[i].address;
668     tree_node * node;
669
670     if (!(node = tree_search(tree_nonrecipients, r)))
671       node = tree_search(tree_nonrecipients, string_copylc(r));
672
673     if ((pp = strstric(r+1, qualify_domain, FALSE)) && *(--pp) == '@')
674        *pp = 0;
675     if (!node)
676       (void)find_dest(p, r, dest_add, FALSE);
677     else
678       (void)find_dest(p, r, dest_remove, FALSE);
679     }
680
681 /* We also need to scan the tree of non-recipients, which might
682 contain child addresses that are not in the recipients list, but
683 which may have got onto the address list as a result of eximon
684 noticing an == line in the log. Then remember the update time,
685 recover the dynamic store, and we are done. */
686
687 scan_tree(p, tree_nonrecipients);
688 p->update_time = statdata.st_mtime;
689 store_reset(reset_point);
690 }
691
692
693
694 /*************************************************
695 *              Display queue data                *
696 *************************************************/
697
698 /* The present implementation simple re-writes the entire information each
699 time. Take some care to keep the scrolled position as it previously was, but,
700 if it was at the bottom, keep it at the bottom. Take note of any hide list, and
701 time out the entries as appropriate. */
702
703 void
704 queue_display(void)
705 {
706 int now = (int)time(NULL);
707 queue_item *p = queue_index[0];
708
709 if (menu_is_up) return;            /* Avoid nasty interactions */
710
711 text_empty(queue_widget);
712
713 while (p != NULL)
714   {
715   int count = 1;
716   dest_item *dd, *ddd;
717   uschar u = 'm';
718   int t = (now - p->input_time)/60;  /* minutes on queue */
719
720   if (t > 90)
721     {
722     u = 'h';
723     t = (t + 30)/60;
724     if (t > 72)
725       {
726       u = 'd';
727       t = (t + 12)/24;
728       if (t > 99)                    /* someone had > 99 days */
729         {
730         u = 'w';
731         t = (t + 3)/7;
732         if (t > 99)                  /* so, just in case */
733           {
734           u = 'y';
735           t = (t + 26)/52;
736           }
737         }
738       }
739     }
740
741   update_recipients(p);                   /* update destinations */
742
743   /* Can't set this earlier, as header data may change things. */
744
745   dd = p->destinations;
746
747   /* Check to see if this message is on the hide list; if any hide
748   item has timed out, remove it from the list. Hide if all destinations
749   are on the hide list. */
750
751   for (ddd = dd; ddd != NULL; ddd = ddd->next)
752     {
753     skip_item *sk;
754     skip_item **skp;
755     int len_address;
756
757     if (ddd->address[0] == '*') break;
758     len_address = Ustrlen(ddd->address);
759
760     for (skp = &queue_skip; ; skp = &(sk->next))
761       {
762       int len_skip;
763
764       sk = *skp;
765       while (sk != NULL && now >= sk->reveal)
766         {
767         *skp = sk->next;
768         store_free(sk);
769         sk = *skp;
770         if (queue_skip == NULL)
771           {
772           XtDestroyWidget(unhide_widget);
773           unhide_widget = NULL;
774           }
775         }
776       if (sk == NULL) break;
777
778       /* If this address matches the skip item, break (sk != NULL) */
779
780       len_skip = Ustrlen(sk->text);
781       if (len_skip <= len_address &&
782           Ustrcmp(ddd->address + len_address - len_skip, sk->text) == 0)
783         break;
784       }
785
786     if (sk == NULL) break;
787     }
788
789   /* Don't use more than one call of anon() in one statement - it uses
790   a fixed static buffer. */
791
792   if (ddd != NULL || dd == NULL)
793     {
794     text_showf(queue_widget, "%c%2d%c %s %s %-8s ",
795       (p->frozen)? '*' : ' ',
796       t, u,
797       string_format_size(p->size, big_buffer),
798       p->name,
799       (p->sender == NULL)? US"       " :
800         (p->sender[0] == 0)? US"<>     " : anon(p->sender));
801
802     text_showf(queue_widget, "%s%s%s",
803       (dd == NULL || dd->address[0] == '*')? "" : "<",
804       (dd == NULL)? US"" : anon(dd->address),
805       (dd == NULL || dd->address[0] == '*')? "" : ">");
806
807     if (dd != NULL && dd->parent != NULL && dd->parent->address[0] != '*')
808       text_showf(queue_widget, " parent <%s>", anon(dd->parent->address));
809
810     text_show(queue_widget, US"\n");
811
812     if (dd != NULL) dd = dd->next;
813     while (dd != NULL && count++ < queue_max_addresses)
814       {
815       text_showf(queue_widget, "                                     <%s>",
816         anon(dd->address));
817       if (dd->parent != NULL && dd->parent->address[0] != '*')
818         text_showf(queue_widget, " parent <%s>", anon(dd->parent->address));
819       text_show(queue_widget, US"\n");
820       dd = dd->next;
821       }
822     if (dd != NULL)
823       text_showf(queue_widget, "                                     ...\n");
824     }
825
826   p = p->next;
827   }
828 }
829
830 /* End of em_queue.c */