1 // Message-related protocol commands for Citadel clients
3 // Copyright (c) 1987-2022 by the citadel.org team
5 // This program is open source software; you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License version 3.
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
14 #include <libcitadel.h>
15 #include "citserver.h"
16 #include "ctdl_module.h"
17 #include "internet_addressing.h"
22 extern char *msgkeys[];
25 // Back end for the MSGS command: output message number only.
26 void simple_listing(long msgnum, void *userdata) {
27 cprintf("%ld\n", msgnum);
31 // Back end for the MSGS command: output header summary.
32 void headers_listing(long msgnum, void *userdata) {
33 struct CtdlMessage *msg;
34 int output_mode = *(int *)userdata;
36 msg = CtdlFetchMessage(msgnum, 0);
38 cprintf("%ld|0|||||||\n", msgnum);
42 // change all vertical bars in the subject to hyphens so it doesn't screw up the protocol
43 if (!CM_IsEmpty(msg, eMsgSubject)) {
45 for (p=msg->cm_fields[eMsgSubject]; *p; p++) {
52 // output all fields except the references hash
53 cprintf("%ld|%s|%s|%s|%s|%s",
55 (!CM_IsEmpty(msg, eTimestamp) ? msg->cm_fields[eTimestamp] : "0"),
56 (!CM_IsEmpty(msg, eAuthor) ? msg->cm_fields[eAuthor] : ""),
57 CtdlGetConfigStr("c_nodename"), // no more nodenames anymore
58 (!CM_IsEmpty(msg, erFc822Addr) ? msg->cm_fields[erFc822Addr] : ""),
59 (!CM_IsEmpty(msg, eMsgSubject) ? msg->cm_fields[eMsgSubject] : "")
62 if (output_mode == MSG_HDRS_THREADS) { // field view with thread hashes
64 // output the references hash
66 (!CM_IsEmpty(msg, emessageId) ? HashLittle(msg->cm_fields[emessageId],strlen(msg->cm_fields[emessageId])) : 0)
69 // output the references hash (yes it's ok that we're trashing the source buffer by doing this)
70 if (!CM_IsEmpty(msg, eWeferences)) {
72 char *rest = msg->cm_fields[eWeferences];
74 while((token = strtok_r(rest, "|", &rest))) {
75 cprintf("%d%s", HashLittle(token,rest-prev-(*rest==0?0:1)), (*rest==0?"":","));
83 else { // field view with no threads, subject extends out forever
90 typedef struct _msg_filter {
97 void headers_brief_filter(long msgnum, void *userdata) {
99 struct CtdlMessage *msg;
100 msg_filter *flt = (msg_filter*) userdata;
102 l = GetCount(flt->Filter);
103 msg = CtdlFetchMessage(msgnum, 0);
104 StrBufPrintf(flt->buffer, "%ld", msgnum);
106 for (i = 0; i < l; i++) {
107 StrBufAppendBufPlain(flt->buffer, HKEY("|"), 0);
114 RewindHashPos(flt->Filter, flt->p, 0);
115 while (GetNextHashPos(flt->Filter, flt->p, &len, &k, &v)) {
116 eMsgField f = (eMsgField) v;
118 StrBufAppendBufPlain(flt->buffer, HKEY("|"), 0);
119 if (!CM_IsEmpty(msg, f)) {
120 StrBufAppendBufPlain(flt->buffer, CM_KEY(msg, f), 0);
124 StrBufAppendBufPlain(flt->buffer, HKEY("\n"), 0);
125 cputbuf(flt->buffer);
128 // Back end for the MSGS command: output EUID header.
129 void headers_euid(long msgnum, void *userdata) {
130 struct CtdlMessage *msg;
132 msg = CtdlFetchMessage(msgnum, 0);
134 cprintf("%ld||\n", msgnum);
138 cprintf("%ld|%s|%s\n",
140 (!CM_IsEmpty(msg, eExclusiveID) ? msg->cm_fields[eExclusiveID] : ""),
141 (!CM_IsEmpty(msg, eTimestamp) ? msg->cm_fields[eTimestamp] : "0"));
146 // cmd_msgs() - get list of message #'s in this room
147 // implements the MSGS server command using CtdlForEachMessage()
148 void cmd_msgs(char *cmdbuf) {
155 int with_template = 0;
156 struct CtdlMessage *template = NULL;
158 char search_string[1024];
159 ForEachMsgCallback CallBack;
161 if (CtdlAccessCheck(ac_logged_in_or_guest)) return;
163 extract_token(which, cmdbuf, 0, '|', sizeof which);
164 cm_ref = extract_int(cmdbuf, 1);
165 extract_token(search_string, cmdbuf, 1, '|', sizeof search_string);
166 with_template = extract_int(cmdbuf, 2);
167 int output_mode = extract_int(cmdbuf, 3);
168 switch (output_mode) {
171 CallBack = simple_listing;
174 case MSG_HDRS_THREADS:
175 CallBack = headers_listing;
178 CallBack = headers_euid;
180 case MSG_HDRS_BRIEFFILTER:
182 CallBack = headers_brief_filter;
187 if (!strncasecmp(which, "OLD", 3))
189 else if (!strncasecmp(which, "NEW", 3))
191 else if (!strncasecmp(which, "FIRST", 5))
193 else if (!strncasecmp(which, "LAST", 4))
195 else if (!strncasecmp(which, "GT", 2))
197 else if (!strncasecmp(which, "LT", 2))
199 else if (!strncasecmp(which, "SEARCH", 6))
204 if ( (mode == MSGS_SEARCH) && (!CtdlGetConfigInt("c_enable_fulltext")) ) {
205 cprintf("%d Full text index is not enabled on this server.\n",
206 ERROR + CMD_NOT_SUPPORTED);
210 if (with_template == 1) {
213 cprintf("%d Send template then receive message list\n",
215 template = (struct CtdlMessage *)
216 malloc(sizeof(struct CtdlMessage));
217 memset(template, 0, sizeof(struct CtdlMessage));
218 template->cm_magic = CTDLMESSAGE_MAGIC;
219 template->cm_anon_type = MES_NORMAL;
221 while(client_getln(buf, sizeof buf) >= 0 && strcmp(buf,"000")) {
225 tValueLen = extract_token(tfield, buf, 0, '|', sizeof tfield);
226 if ((tValueLen == 4) && GetFieldFromMnemonic(&f, tfield))
228 tValueLen = extract_token(tvalue, buf, 1, '|', sizeof tvalue);
229 if (tValueLen >= 0) {
230 CM_SetField(template, f, tvalue, tValueLen);
236 else if (with_template == 2) {
239 cprintf("%d Send list of headers\n",
241 filt.Filter = NewHash(1, lFlathash);
242 filt.buffer = NewStrBufPlain(NULL, 1024);
243 while(client_getln(buf, sizeof buf) >= 0 && strcmp(buf,"000")) {
246 if (GetFieldFromMnemonic(&f, buf))
248 Put(filt.Filter, LKEY(i), (void*)f, reference_free_handler);
252 filt.p = GetNewHashPos(filt.Filter, 0);
256 cprintf("%d \n", LISTING_FOLLOWS);
259 if (with_template < 2) {
260 CtdlForEachMessage(mode,
261 ( (mode == MSGS_SEARCH) ? 0 : cm_ref ),
262 ( (mode == MSGS_SEARCH) ? search_string : NULL ),
267 if (template != NULL) CM_Free(template);
270 CtdlForEachMessage(mode,
271 ( (mode == MSGS_SEARCH) ? 0 : cm_ref ),
272 ( (mode == MSGS_SEARCH) ? search_string : NULL ),
277 DeleteHashPos(&filt.p);
278 DeleteHash(&filt.Filter);
279 FreeStrBuf(&filt.buffer);
287 * display a message (mode 0 - Citadel proprietary)
289 void cmd_msg0(char *cmdbuf)
292 int headers_only = HEADERS_ALL;
294 msgid = extract_long(cmdbuf, 0);
295 headers_only = extract_int(cmdbuf, 1);
297 CtdlOutputMsg(msgid, MT_CITADEL, headers_only, 1, 0, NULL, 0, NULL, NULL, NULL);
302 // display a message (mode 2 - RFC822)
303 void cmd_msg2(char *cmdbuf) {
305 int headers_only = HEADERS_ALL;
307 msgid = extract_long(cmdbuf, 0);
308 headers_only = extract_int(cmdbuf, 1);
310 CtdlOutputMsg(msgid, MT_RFC822, headers_only, 1, 1, NULL, 0, NULL, NULL, NULL);
314 // Display a message using MIME content types
315 void cmd_msg4(char *cmdbuf) {
319 msgid = extract_long(cmdbuf, 0);
320 extract_token(section, cmdbuf, 1, '|', sizeof section);
321 CtdlOutputMsg(msgid, MT_MIME, 0, 1, 0, (section[0] ? section : NULL) , 0, NULL, NULL, NULL);
325 // Client tells us its preferred message format(s)
326 void cmd_msgp(char *cmdbuf) {
327 if (!strcasecmp(cmdbuf, "dont_decode")) {
328 CC->msg4_dont_decode = 1;
329 cprintf("%d MSG4 will not pre-decode messages.\n", CIT_OK);
332 safestrncpy(CC->preferred_formats, cmdbuf, sizeof(CC->preferred_formats));
333 cprintf("%d Preferred MIME formats have been set.\n", CIT_OK);
338 // Open a component of a MIME message as a download file
339 void cmd_opna(char *cmdbuf) {
341 char desired_section[128];
343 msgid = extract_long(cmdbuf, 0);
344 extract_token(desired_section, cmdbuf, 1, '|', sizeof desired_section);
345 safestrncpy(CC->download_desired_section, desired_section,
346 sizeof CC->download_desired_section);
347 CtdlOutputMsg(msgid, MT_DOWNLOAD, 0, 1, 1, NULL, 0, NULL, NULL, NULL);
351 // Open a component of a MIME message and transmit it all at once
352 void cmd_dlat(char *cmdbuf) {
354 char desired_section[128];
356 msgid = extract_long(cmdbuf, 0);
357 extract_token(desired_section, cmdbuf, 1, '|', sizeof desired_section);
358 safestrncpy(CC->download_desired_section, desired_section, sizeof CC->download_desired_section);
359 CtdlOutputMsg(msgid, MT_SPEW_SECTION, 0, 1, 1, NULL, 0, NULL, NULL, NULL);
363 // message entry - mode 0 (normal)
364 void cmd_ent0(char *entargs) {
369 char supplied_euid[128];
372 char newusername[256];
373 char newuseremail[256];
374 struct CtdlMessage *msg;
378 struct recptypes *valid = NULL;
379 struct recptypes *valid_to = NULL;
380 struct recptypes *valid_cc = NULL;
381 struct recptypes *valid_bcc = NULL;
383 int subject_required = 0;
388 int newuseremail_ok = 0;
389 char references[SIZ];
394 post = extract_int(entargs, 0);
395 extract_token(recp, entargs, 1, '|', sizeof recp);
396 anon_flag = extract_int(entargs, 2);
397 format_type = extract_int(entargs, 3);
398 extract_token(subject, entargs, 4, '|', sizeof subject);
399 extract_token(newusername, entargs, 5, '|', sizeof newusername);
400 do_confirm = extract_int(entargs, 6);
401 extract_token(cc, entargs, 7, '|', sizeof cc);
402 extract_token(bcc, entargs, 8, '|', sizeof bcc);
403 switch(CC->room.QRdefaultview) {
406 extract_token(supplied_euid, entargs, 9, '|', sizeof supplied_euid);
409 supplied_euid[0] = 0;
412 extract_token(newuseremail, entargs, 10, '|', sizeof newuseremail);
413 extract_token(references, entargs, 11, '|', sizeof references);
414 for (ptr=references; *ptr != 0; ++ptr) {
415 if (*ptr == '!') *ptr = '|';
418 /* first check to make sure the request is valid. */
420 err = CtdlDoIHavePermissionToPostInThisRoom(
425 (!IsEmptyStr(references)) // is this a reply? or a top-level post?
428 cprintf("%d %s\n", err, errmsg);
432 /* Check some other permission type things. */
434 if (IsEmptyStr(newusername)) {
435 strcpy(newusername, CC->user.fullname);
437 if ( (CC->user.axlevel < AxAideU)
438 && (strcasecmp(newusername, CC->user.fullname))
439 && (strcasecmp(newusername, CC->cs_inet_fn))
441 cprintf("%d You don't have permission to author messages as '%s'.\n",
442 ERROR + HIGHER_ACCESS_REQUIRED,
448 if (IsEmptyStr(newuseremail)) {
452 if (!IsEmptyStr(newuseremail)) {
453 if (!strcasecmp(newuseremail, CC->cs_inet_email)) {
456 else if (!IsEmptyStr(CC->cs_inet_other_emails)) {
457 j = num_tokens(CC->cs_inet_other_emails, '|');
458 for (i=0; i<j; ++i) {
459 extract_token(buf, CC->cs_inet_other_emails, i, '|', sizeof buf);
460 if (!strcasecmp(newuseremail, buf)) {
467 if (!newuseremail_ok) {
468 cprintf("%d You don't have permission to author messages as '%s'.\n",
469 ERROR + HIGHER_ACCESS_REQUIRED,
475 CC->cs_flags |= CS_POSTING;
477 // In mailbox rooms we have to behave a little differently --
478 // make sure the user has specified at least one recipient. Then
479 // validate the recipient(s). We do this for the Mail> room, as
480 // well as any room which has the "Mailbox" view set - unless it
481 // is the DRAFTS room which does not require recipients.
483 if ( ( ( (CC->room.QRflags & QR_MAILBOX) && (!strcasecmp(&CC->room.QRname[11], MAILROOM)) )
484 || ( (CC->room.QRflags & QR_MAILBOX) && (CC->curr_view == VIEW_MAILBOX) )
485 ) && (strcasecmp(&CC->room.QRname[11], USERDRAFTROOM)) !=0 ) {
486 if (CC->user.axlevel < AxProbU) {
487 strcpy(recp, "sysop");
492 valid_to = validate_recipients(recp, NULL, 0);
493 if (valid_to->num_error > 0) {
494 cprintf("%d %s\n", ERROR + NO_SUCH_USER, valid_to->errormsg);
495 free_recipients(valid_to);
499 valid_cc = validate_recipients(cc, NULL, 0);
500 if (valid_cc->num_error > 0) {
501 cprintf("%d %s\n", ERROR + NO_SUCH_USER, valid_cc->errormsg);
502 free_recipients(valid_to);
503 free_recipients(valid_cc);
507 valid_bcc = validate_recipients(bcc, NULL, 0);
508 if (valid_bcc->num_error > 0) {
509 cprintf("%d %s\n", ERROR + NO_SUCH_USER, valid_bcc->errormsg);
510 free_recipients(valid_to);
511 free_recipients(valid_cc);
512 free_recipients(valid_bcc);
516 // Recipient required, but none were specified
517 if ( (valid_to->num_error < 0) && (valid_cc->num_error < 0) && (valid_bcc->num_error < 0) ) {
518 free_recipients(valid_to);
519 free_recipients(valid_cc);
520 free_recipients(valid_bcc);
521 cprintf("%d At least one recipient is required.\n", ERROR + NO_SUCH_USER);
525 if (valid_to->num_internet + valid_cc->num_internet + valid_bcc->num_internet > 0) {
526 if (CtdlCheckInternetMailPermission(&CC->user)==0) {
527 cprintf("%d You do not have permission "
528 "to send Internet mail.\n",
529 ERROR + HIGHER_ACCESS_REQUIRED);
530 free_recipients(valid_to);
531 free_recipients(valid_cc);
532 free_recipients(valid_bcc);
537 if ( ( (valid_to->num_internet + valid_cc->num_internet + valid_bcc->num_internet) > 0) && (CC->user.axlevel < AxNetU) ) {
538 cprintf("%d Higher access required for network mail.\n", ERROR + HIGHER_ACCESS_REQUIRED);
539 free_recipients(valid_to);
540 free_recipients(valid_cc);
541 free_recipients(valid_bcc);
545 if ((RESTRICT_INTERNET == 1)
546 && (valid_to->num_internet + valid_cc->num_internet + valid_bcc->num_internet > 0)
547 && ((CC->user.flags & US_INTERNET) == 0)
548 && (!CC->internal_pgm)) {
549 cprintf("%d You don't have access to Internet mail.\n",
550 ERROR + HIGHER_ACCESS_REQUIRED);
551 free_recipients(valid_to);
552 free_recipients(valid_cc);
553 free_recipients(valid_bcc);
559 // Is this a room which has anonymous-only or anonymous-option?
560 anonymous = MES_NORMAL;
561 if (CC->room.QRflags & QR_ANONONLY) {
562 anonymous = MES_ANONONLY;
564 if (CC->room.QRflags & QR_ANONOPT) {
565 if (anon_flag == 1) { // only if the user requested it
566 anonymous = MES_ANONOPT;
570 if ((CC->room.QRflags & QR_MAILBOX) == 0) {
574 // Recommend to the client that the use of a message subject is
575 // strongly recommended in this room, if either the SUBJECTREQ flag
576 // is set, or if there is one or more Internet email recipients.
578 if (CC->room.QRflags2 & QR2_SUBJECTREQ) subject_required = 1;
579 if ((valid_to) && (valid_to->num_internet > 0)) subject_required = 1;
580 if ((valid_cc) && (valid_cc->num_internet > 0)) subject_required = 1;
581 if ((valid_bcc) && (valid_bcc->num_internet > 0)) subject_required = 1;
583 // If we're only checking the validity of the request, return success without creating the message.
585 cprintf("%d %s|%d\n", CIT_OK,
586 ((valid_to != NULL) ? valid_to->display_recp : ""),
588 free_recipients(valid_to);
589 free_recipients(valid_cc);
590 free_recipients(valid_bcc);
594 // We don't need these anymore because we'll do it differently below
595 free_recipients(valid_to);
596 free_recipients(valid_cc);
597 free_recipients(valid_bcc);
599 // Read in the message from the client.
601 cprintf("%d send message\n", START_CHAT_MODE);
604 cprintf("%d send message\n", SEND_LISTING);
607 msg = CtdlMakeMessage(&CC->user, recp, cc,
608 CC->room.QRname, anonymous, format_type,
609 newusername, newuseremail, subject,
610 ((!IsEmptyStr(supplied_euid)) ? supplied_euid : NULL),
613 // Put together one big recipients struct containing to/cc/bcc all in one. This is for the envelope.
614 char *all_recps = malloc(SIZ * 3);
615 strcpy(all_recps, recp);
616 if (!IsEmptyStr(cc)) {
617 if (!IsEmptyStr(all_recps)) {
618 strcat(all_recps, ",");
620 strcat(all_recps, cc);
622 if (!IsEmptyStr(bcc)) {
623 if (!IsEmptyStr(all_recps)) {
624 strcat(all_recps, ",");
626 strcat(all_recps, bcc);
628 if (!IsEmptyStr(all_recps)) {
629 valid = validate_recipients(all_recps, NULL, 0);
636 // posting into a mailing list room? set the envelope from
637 // to the actual mail address so others get a valid reply-to-header.
638 if ((valid != NULL) && (valid->num_room == 1) && !IsEmptyStr(valid->recp_orgroom)) {
639 CM_SetField(msg, eenVelopeTo, valid->recp_orgroom, strlen(valid->recp_orgroom));
643 msgnum = CtdlSubmitMsg(msg, valid, "");
645 cprintf("%ld\n", msgnum);
647 if (StrLength(CC->StatusMessage) > 0) {
648 cprintf("%s\n", ChrPtr(CC->StatusMessage));
650 else if (msgnum >= 0L) {
651 client_write(HKEY("Message accepted.\n"));
654 client_write(HKEY("Internal error.\n"));
657 if (!CM_IsEmpty(msg, eExclusiveID)) {
658 cprintf("%s\n", msg->cm_fields[eExclusiveID]);
668 free_recipients(valid);
674 // Delete message from current room
675 void cmd_dele(char *args) {
683 extract_token(msgset, args, 0, '|', sizeof msgset);
684 num_msgs = num_tokens(msgset, ',');
686 cprintf("%d Nothing to do.\n", CIT_OK);
690 if (CtdlDoIHavePermissionToDeleteMessagesFromThisRoom() == 0) {
691 cprintf("%d Higher access required.\n",
692 ERROR + HIGHER_ACCESS_REQUIRED);
696 // Build our message set to be moved/copied
697 msgs = malloc(num_msgs * sizeof(long));
698 for (i=0; i<num_msgs; ++i) {
699 extract_token(msgtok, msgset, i, ',', sizeof msgtok);
700 msgs[i] = atol(msgtok);
703 num_deleted = CtdlDeleteMessages(CC->room.QRname, msgs, num_msgs, "");
707 cprintf("%d %d message%s deleted.\n", CIT_OK,
708 num_deleted, ((num_deleted != 1) ? "s" : ""));
710 cprintf("%d Message not found.\n", ERROR + MESSAGE_NOT_FOUND);
715 // move or copy a message to another room
716 void cmd_move(char *args) {
722 char targ[ROOMNAMELEN];
723 struct ctdlroom qtemp;
730 extract_token(msgset, args, 0, '|', sizeof msgset);
731 num_msgs = num_tokens(msgset, ',');
733 cprintf("%d Nothing to do.\n", CIT_OK);
737 extract_token(targ, args, 1, '|', sizeof targ);
738 convert_room_name_macros(targ, sizeof targ);
739 targ[ROOMNAMELEN - 1] = 0;
740 is_copy = extract_int(args, 2);
742 if (CtdlGetRoom(&qtemp, targ) != 0) {
743 cprintf("%d '%s' does not exist.\n", ERROR + ROOM_NOT_FOUND, targ);
747 if (!strcasecmp(qtemp.QRname, CC->room.QRname)) {
748 cprintf("%d Source and target rooms are the same.\n", ERROR + ALREADY_EXISTS);
752 CtdlGetUser(&CC->user, CC->curr_user);
753 CtdlRoomAccess(&qtemp, &CC->user, &ra, NULL);
755 // Check for permission to perform this operation.
756 // Remember: "CC->room" is source, "qtemp" is target.
759 // Admins can move/copy
760 if (CC->user.axlevel >= AxAideU) permit = 1;
762 // Room aides can move/copy
763 if (CC->user.usernum == CC->room.QRroomaide) permit = 1;
765 // Permit move/copy from personal rooms
766 if ((CC->room.QRflags & QR_MAILBOX)
767 && (qtemp.QRflags & QR_MAILBOX)) permit = 1;
769 // Permit only copy from public to personal room
771 && (!(CC->room.QRflags & QR_MAILBOX))
772 && (qtemp.QRflags & QR_MAILBOX)
777 // Permit message removal from collaborative delete rooms
778 if (CC->room.QRflags2 & QR2_COLLABDEL) permit = 1;
780 // Users allowed to post into the target room may move into it too.
781 if ((CC->room.QRflags & QR_MAILBOX) &&
782 (qtemp.QRflags & UA_POSTALLOWED)) permit = 1;
784 // User must have access to target room
785 if (!(ra & UA_KNOWN)) permit = 0;
788 cprintf("%d Higher access required.\n",
789 ERROR + HIGHER_ACCESS_REQUIRED);
793 // Build our message set to be moved/copied
794 msgs = malloc(num_msgs * sizeof(long));
795 for (i=0; i<num_msgs; ++i) {
796 extract_token(msgtok, msgset, i, ',', sizeof msgtok);
797 msgs[i] = atol(msgtok);
801 err = CtdlSaveMsgPointersInRoom(targ, msgs, num_msgs, 1, NULL, 0);
803 cprintf("%d Cannot store message(s) in %s: error %d\n",
809 // Now delete the message from the source room, if this is a 'move' rather than a 'copy' operation.
811 CtdlDeleteMessages(CC->room.QRname, msgs, num_msgs, "");
815 cprintf("%d Message(s) %s.\n", CIT_OK, (is_copy ? "copied" : "moved") );
819 /*****************************************************************************/
820 /* MODULE INITIALIZATION STUFF */
821 /*****************************************************************************/
822 CTDL_MODULE_INIT(ctdl_message)
826 CtdlRegisterProtoHook(cmd_msgs, "MSGS", "Output a list of messages in the current room");
827 CtdlRegisterProtoHook(cmd_msg0, "MSG0", "Output a message in plain text format");
828 CtdlRegisterProtoHook(cmd_msg2, "MSG2", "Output a message in RFC822 format");
829 CtdlRegisterProtoHook(cmd_msg4, "MSG4", "Output a message in the client's preferred format");
830 CtdlRegisterProtoHook(cmd_msgp, "MSGP", "Select preferred format for MSG4 output");
831 CtdlRegisterProtoHook(cmd_opna, "OPNA", "Open an attachment for download");
832 CtdlRegisterProtoHook(cmd_dlat, "DLAT", "Download an attachment");
833 CtdlRegisterProtoHook(cmd_ent0, "ENT0", "Enter a message");
834 CtdlRegisterProtoHook(cmd_dele, "DELE", "Delete a message");
835 CtdlRegisterProtoHook(cmd_move, "MOVE", "Move or copy a message to another room");
838 /* return our Subversion id for the Log */
839 return "ctdl_message";