1 // Message-related protocol commands for Citadel clients
3 // Copyright (c) 1987-2024 by the citadel.org team
5 // This program is open source software. Use, duplication, or disclosure
6 // is subject to the terms of the GNU General Public License version 3.
9 #include <libcitadel.h>
10 #include "../../citserver.h"
11 #include "../../ctdl_module.h"
12 #include "../../internet_addressing.h"
13 #include "../../user_ops.h"
14 #include "../../room_ops.h"
15 #include "../../config.h"
17 extern char *msgkeys[];
20 // Back end for the MSGS command: output message number only.
21 void simple_listing(long msgnum, void *userdata) {
22 cprintf("%ld\n", msgnum);
26 // Back end for the MSGS command: output header summary.
27 void headers_listing(long msgnum, void *userdata) {
28 struct CtdlMessage *msg;
29 int output_mode = *(int *)userdata;
31 msg = CtdlFetchMessage(msgnum, 0);
33 cprintf("%ld|0|||||||\n", msgnum);
37 // change all vertical bars in the subject to hyphens so it doesn't screw up the protocol
38 if (!CM_IsEmpty(msg, eMsgSubject)) {
40 for (p=msg->cm_fields[eMsgSubject]; *p; p++) {
47 // output all fields except the references hash
48 cprintf("%ld|%s|%s|%s|%s|%s",
50 (!CM_IsEmpty(msg, eTimestamp) ? msg->cm_fields[eTimestamp] : "0"),
51 (!CM_IsEmpty(msg, eAuthor) ? msg->cm_fields[eAuthor] : ""),
52 CtdlGetConfigStr("c_nodename"), // no more nodenames anymore
53 (!CM_IsEmpty(msg, erFc822Addr) ? msg->cm_fields[erFc822Addr] : ""),
54 (!CM_IsEmpty(msg, eMsgSubject) ? msg->cm_fields[eMsgSubject] : "")
57 if (output_mode == MSG_HDRS_THREADS) { // field view with thread hashes
59 // output the references hash
61 (!CM_IsEmpty(msg, emessageId) ? HashLittle(msg->cm_fields[emessageId],strlen(msg->cm_fields[emessageId])) : 0)
64 // output the references hash (yes it's ok that we're trashing the source buffer by doing this)
65 if (!CM_IsEmpty(msg, eWeferences)) {
67 char *rest = msg->cm_fields[eWeferences];
69 while((token = strtok_r(rest, "|", &rest))) {
70 cprintf("%d%s", HashLittle(token,rest-prev-(*rest==0?0:1)), (*rest==0?"":","));
78 else { // field view with no threads, subject extends out forever
85 typedef struct _msg_filter {
92 void headers_brief_filter(long msgnum, void *userdata) {
94 struct CtdlMessage *msg;
95 msg_filter *flt = (msg_filter*) userdata;
97 l = GetCount(flt->Filter);
98 msg = CtdlFetchMessage(msgnum, 0);
99 StrBufPrintf(flt->buffer, "%ld", msgnum);
101 for (i = 0; i < l; i++) {
102 StrBufAppendBufPlain(flt->buffer, HKEY("|"), 0);
109 RewindHashPos(flt->Filter, flt->p, 0);
110 while (GetNextHashPos(flt->Filter, flt->p, &len, &k, &v)) {
111 eMsgField f = (eMsgField) v;
113 StrBufAppendBufPlain(flt->buffer, HKEY("|"), 0);
114 if (!CM_IsEmpty(msg, f)) {
115 StrBufAppendBufPlain(flt->buffer, CM_KEY(msg, f), 0);
119 StrBufAppendBufPlain(flt->buffer, HKEY("\n"), 0);
120 cputbuf(flt->buffer);
123 // Back end for the MSGS command: output EUID header.
124 void headers_euid(long msgnum, void *userdata) {
125 struct CtdlMessage *msg;
127 msg = CtdlFetchMessage(msgnum, 0);
129 cprintf("%ld||\n", msgnum);
133 cprintf("%ld|%s|%s\n",
135 (!CM_IsEmpty(msg, eExclusiveID) ? msg->cm_fields[eExclusiveID] : ""),
136 (!CM_IsEmpty(msg, eTimestamp) ? msg->cm_fields[eTimestamp] : "0"));
141 // cmd_msgs() - get list of message #'s in this room
142 // implements the MSGS server command using CtdlForEachMessage()
143 void cmd_msgs(char *cmdbuf) {
150 int with_template = 0;
151 struct CtdlMessage *template = NULL;
153 char search_string[1024];
154 ForEachMsgCallback CallBack;
156 if (CtdlAccessCheck(ac_logged_in_or_guest)) return;
158 extract_token(which, cmdbuf, 0, '|', sizeof which);
159 cm_ref = extract_int(cmdbuf, 1);
160 extract_token(search_string, cmdbuf, 1, '|', sizeof search_string);
161 with_template = extract_int(cmdbuf, 2);
162 int output_mode = extract_int(cmdbuf, 3);
163 switch (output_mode) {
166 CallBack = simple_listing;
169 case MSG_HDRS_THREADS:
170 CallBack = headers_listing;
173 CallBack = headers_euid;
175 case MSG_HDRS_BRIEFFILTER:
177 CallBack = headers_brief_filter;
182 if (!strncasecmp(which, "OLD", 3))
184 else if (!strncasecmp(which, "NEW", 3))
186 else if (!strncasecmp(which, "FIRST", 5))
188 else if (!strncasecmp(which, "LAST", 4))
190 else if (!strncasecmp(which, "GT", 2))
192 else if (!strncasecmp(which, "LT", 2))
194 else if (!strncasecmp(which, "SEARCH", 6))
199 if ( (mode == MSGS_SEARCH) && (!CtdlGetConfigInt("c_enable_fulltext")) ) {
200 cprintf("%d Full text index is not enabled on this server.\n",
201 ERROR + CMD_NOT_SUPPORTED);
205 if (with_template == 1) {
208 cprintf("%d Send template then receive message list\n", START_CHAT_MODE);
209 template = (struct CtdlMessage *) malloc(sizeof(struct CtdlMessage));
210 memset(template, 0, sizeof(struct CtdlMessage));
211 template->cm_magic = CTDLMESSAGE_MAGIC;
212 template->cm_anon_type = MES_NORMAL;
214 while(client_getln(buf, sizeof buf) >= 0 && strcmp(buf,"000")) {
218 tValueLen = extract_token(tfield, buf, 0, '|', sizeof tfield);
219 if ((tValueLen == 4) && GetFieldFromMnemonic(&f, tfield)) {
220 tValueLen = extract_token(tvalue, buf, 1, '|', sizeof tvalue);
221 if (tValueLen >= 0) {
222 CM_SetField(template, f, tvalue);
228 else if (with_template == 2) {
231 cprintf("%d Send list of headers\n",
233 filt.Filter = NewHash(1, lFlathash);
234 filt.buffer = NewStrBufPlain(NULL, 1024);
235 while(client_getln(buf, sizeof buf) >= 0 && strcmp(buf,"000")) {
238 if (GetFieldFromMnemonic(&f, buf)) {
239 Put(filt.Filter, LKEY(i), (void*)f, reference_free_handler);
243 filt.p = GetNewHashPos(filt.Filter, 0);
247 cprintf("%d \n", LISTING_FOLLOWS);
250 if (with_template < 2) {
251 CtdlForEachMessage(mode,
252 ( (mode == MSGS_SEARCH) ? 0 : cm_ref ),
253 ( (mode == MSGS_SEARCH) ? search_string : NULL ),
258 if (template != NULL) CM_Free(template);
261 CtdlForEachMessage(mode,
262 ( (mode == MSGS_SEARCH) ? 0 : cm_ref ),
263 ( (mode == MSGS_SEARCH) ? search_string : NULL ),
268 DeleteHashPos(&filt.p);
269 DeleteHash(&filt.Filter);
270 FreeStrBuf(&filt.buffer);
277 // display a message (mode 0 - Citadel proprietary)
278 void cmd_msg0(char *cmdbuf) {
280 int headers_only = HEADERS_ALL;
282 msgid = extract_long(cmdbuf, 0);
283 headers_only = extract_int(cmdbuf, 1);
285 CtdlOutputMsg(msgid, MT_CITADEL, headers_only, 1, 0, NULL, 0, NULL, NULL, NULL);
290 // display a message (mode 2 - RFC822)
291 void cmd_msg2(char *cmdbuf) {
293 int headers_only = HEADERS_ALL;
295 msgid = extract_long(cmdbuf, 0);
296 headers_only = extract_int(cmdbuf, 1);
298 CtdlOutputMsg(msgid, MT_RFC822, headers_only, 1, 1, NULL, 0, NULL, NULL, NULL);
302 // Display a message using MIME content types
303 void cmd_msg4(char *cmdbuf) {
307 msgid = extract_long(cmdbuf, 0);
308 extract_token(section, cmdbuf, 1, '|', sizeof section);
309 CtdlOutputMsg(msgid, MT_MIME, 0, 1, 0, (section[0] ? section : NULL) , 0, NULL, NULL, NULL);
313 // Client tells us its preferred message format(s)
314 void cmd_msgp(char *cmdbuf) {
315 if (!strcasecmp(cmdbuf, "dont_decode")) {
316 CC->msg4_dont_decode = 1;
317 cprintf("%d MSG4 will not pre-decode messages.\n", CIT_OK);
320 safestrncpy(CC->preferred_formats, cmdbuf, sizeof(CC->preferred_formats));
321 cprintf("%d Preferred MIME formats have been set.\n", CIT_OK);
326 // Open a component of a MIME message as a download file
327 void cmd_opna(char *cmdbuf) {
329 char desired_section[128];
331 msgid = extract_long(cmdbuf, 0);
332 extract_token(desired_section, cmdbuf, 1, '|', sizeof desired_section);
333 safestrncpy(CC->download_desired_section, desired_section, sizeof CC->download_desired_section);
334 CtdlOutputMsg(msgid, MT_DOWNLOAD, 0, 1, 1, NULL, 0, NULL, NULL, NULL);
338 // Open a component of a MIME message and transmit it all at once
339 void cmd_dlat(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, sizeof CC->download_desired_section);
346 CtdlOutputMsg(msgid, MT_SPEW_SECTION, 0, 1, 1, NULL, 0, NULL, NULL, NULL);
350 // message entry - mode 0 (normal)
351 void cmd_ent0(char *entargs) {
356 char supplied_euid[128];
359 char newusername[256];
360 char newuseremail[256];
361 struct CtdlMessage *msg;
365 struct recptypes *valid = NULL;
366 struct recptypes *valid_to = NULL;
367 struct recptypes *valid_cc = NULL;
368 struct recptypes *valid_bcc = NULL;
370 int subject_required = 0;
375 int newuseremail_ok = 0;
376 char references[SIZ];
381 post = extract_int(entargs, 0);
382 extract_token(recp, entargs, 1, '|', sizeof recp);
383 anon_flag = extract_int(entargs, 2);
384 format_type = extract_int(entargs, 3);
385 extract_token(subject, entargs, 4, '|', sizeof subject);
386 extract_token(newusername, entargs, 5, '|', sizeof newusername);
387 do_confirm = extract_int(entargs, 6);
388 extract_token(cc, entargs, 7, '|', sizeof cc);
389 extract_token(bcc, entargs, 8, '|', sizeof bcc);
390 switch(CC->room.QRdefaultview) {
393 extract_token(supplied_euid, entargs, 9, '|', sizeof supplied_euid);
396 supplied_euid[0] = 0;
399 extract_token(newuseremail, entargs, 10, '|', sizeof newuseremail);
400 extract_token(references, entargs, 11, '|', sizeof references);
401 for (ptr=references; *ptr != 0; ++ptr) {
402 if (*ptr == '!') *ptr = '|';
405 // first check to make sure the request is valid.
407 err = CtdlDoIHavePermissionToPostInThisRoom(
411 (!IsEmptyStr(references)) // is this a reply? or a top-level post?
414 cprintf("%d %s\n", err, errmsg);
418 // Check some other permission type things.
420 if (IsEmptyStr(newusername)) {
421 strcpy(newusername, CC->user.fullname);
423 if ( (CC->user.axlevel < AxAideU)
424 && (strcasecmp(newusername, CC->user.fullname))
425 && (strcasecmp(newusername, CC->cs_inet_fn))
427 cprintf("%d You don't have permission to author messages as '%s'.\n",
428 ERROR + HIGHER_ACCESS_REQUIRED,
434 if (IsEmptyStr(newuseremail)) {
438 if (!IsEmptyStr(newuseremail)) {
439 if (!strcasecmp(newuseremail, CC->cs_inet_email)) {
442 else if (!IsEmptyStr(CC->cs_inet_other_emails)) {
443 j = num_tokens(CC->cs_inet_other_emails, '|');
444 for (i=0; i<j; ++i) {
445 extract_token(buf, CC->cs_inet_other_emails, i, '|', sizeof buf);
446 if (!strcasecmp(newuseremail, buf)) {
453 if (!newuseremail_ok) {
454 cprintf("%d You don't have permission to author messages as '%s'.\n",
455 ERROR + HIGHER_ACCESS_REQUIRED,
461 CC->cs_flags |= CS_POSTING;
463 // In mailbox rooms we have to behave a little differently --
464 // make sure the user has specified at least one recipient. Then
465 // validate the recipient(s). We do this for the Mail> room, as
466 // well as any room which has the "Mailbox" view set - unless it
467 // is the DRAFTS room which does not require recipients.
469 if ( ( ( (CC->room.QRflags & QR_MAILBOX) && (!strcasecmp(&CC->room.QRname[11], MAILROOM)) )
470 || ( (CC->room.QRflags & QR_MAILBOX) && (CC->curr_view == VIEW_MAILBOX) )
471 ) && (strcasecmp(&CC->room.QRname[11], USERDRAFTROOM)) !=0 ) {
472 if (CC->user.axlevel < AxProbU) {
473 strcpy(recp, "sysop");
478 valid_to = validate_recipients(recp, NULL, 0);
479 if (valid_to->num_error > 0) {
480 cprintf("%d %s\n", ERROR + NO_SUCH_USER, valid_to->errormsg);
481 free_recipients(valid_to);
485 valid_cc = validate_recipients(cc, NULL, 0);
486 if (valid_cc->num_error > 0) {
487 cprintf("%d %s\n", ERROR + NO_SUCH_USER, valid_cc->errormsg);
488 free_recipients(valid_to);
489 free_recipients(valid_cc);
493 valid_bcc = validate_recipients(bcc, NULL, 0);
494 if (valid_bcc->num_error > 0) {
495 cprintf("%d %s\n", ERROR + NO_SUCH_USER, valid_bcc->errormsg);
496 free_recipients(valid_to);
497 free_recipients(valid_cc);
498 free_recipients(valid_bcc);
502 // Recipient required, but none were specified
503 if ( (valid_to->num_error < 0) && (valid_cc->num_error < 0) && (valid_bcc->num_error < 0) ) {
504 free_recipients(valid_to);
505 free_recipients(valid_cc);
506 free_recipients(valid_bcc);
507 cprintf("%d At least one recipient is required.\n", ERROR + NO_SUCH_USER);
511 if (valid_to->num_internet + valid_cc->num_internet + valid_bcc->num_internet > 0) {
512 if (CtdlCheckInternetMailPermission(&CC->user)==0) {
513 cprintf("%d You do not have permission to send Internet mail.\n",
514 ERROR + HIGHER_ACCESS_REQUIRED);
515 free_recipients(valid_to);
516 free_recipients(valid_cc);
517 free_recipients(valid_bcc);
522 if ( ( (valid_to->num_internet + valid_cc->num_internet + valid_bcc->num_internet) > 0) && (CC->user.axlevel < AxNetU) ) {
523 cprintf("%d Higher access required for network mail.\n", ERROR + HIGHER_ACCESS_REQUIRED);
524 free_recipients(valid_to);
525 free_recipients(valid_cc);
526 free_recipients(valid_bcc);
530 if ((RESTRICT_INTERNET == 1)
531 && (valid_to->num_internet + valid_cc->num_internet + valid_bcc->num_internet > 0)
532 && ((CC->user.flags & US_INTERNET) == 0)
533 && (!CC->internal_pgm)) {
534 cprintf("%d You don't have access to Internet mail.\n", ERROR + HIGHER_ACCESS_REQUIRED);
535 free_recipients(valid_to);
536 free_recipients(valid_cc);
537 free_recipients(valid_bcc);
543 // Is this a room which has anonymous-only or anonymous-option?
544 anonymous = MES_NORMAL;
545 if (CC->room.QRflags & QR_ANONONLY) {
546 anonymous = MES_ANONONLY;
548 if (CC->room.QRflags & QR_ANONOPT) {
549 if (anon_flag == 1) { // only if the user requested it
550 anonymous = MES_ANONOPT;
554 if ((CC->room.QRflags & QR_MAILBOX) == 0) {
558 // Recommend to the client that the use of a message subject is
559 // strongly recommended in this room, if either the SUBJECTREQ flag
560 // is set, or if there is one or more Internet email recipients.
562 if (CC->room.QRflags2 & QR2_SUBJECTREQ) subject_required = 1;
563 if ((valid_to) && (valid_to->num_internet > 0)) subject_required = 1;
564 if ((valid_cc) && (valid_cc->num_internet > 0)) subject_required = 1;
565 if ((valid_bcc) && (valid_bcc->num_internet > 0)) subject_required = 1;
567 // If we're only checking the validity of the request, return success without creating the message.
569 cprintf("%d %s|%d\n", CIT_OK,
570 ((valid_to != NULL) ? valid_to->display_recp : ""),
572 free_recipients(valid_to);
573 free_recipients(valid_cc);
574 free_recipients(valid_bcc);
578 // We don't need these anymore because we'll do it differently below
579 free_recipients(valid_to);
580 free_recipients(valid_cc);
581 free_recipients(valid_bcc);
583 // Read in the message from the client.
585 cprintf("%d send message\n", START_CHAT_MODE);
588 cprintf("%d send message\n", SEND_LISTING);
591 msg = CtdlMakeMessage(&CC->user, recp, cc,
592 CC->room.QRname, anonymous, format_type,
593 newusername, newuseremail, subject,
594 ((!IsEmptyStr(supplied_euid)) ? supplied_euid : NULL),
597 // Put together one big recipients struct containing to/cc/bcc all in one. This is for the envelope.
598 char *all_recps = malloc(SIZ * 3);
599 strcpy(all_recps, recp);
600 if (!IsEmptyStr(cc)) {
601 if (!IsEmptyStr(all_recps)) {
602 strcat(all_recps, ",");
604 strcat(all_recps, cc);
606 if (!IsEmptyStr(bcc)) {
607 if (!IsEmptyStr(all_recps)) {
608 strcat(all_recps, ",");
610 strcat(all_recps, bcc);
612 if (!IsEmptyStr(all_recps)) {
613 valid = validate_recipients(all_recps, NULL, 0);
620 // posting into a mailing list room? set the envelope from
621 // to the actual mail address so others get a valid reply-to-header.
622 if ((valid != NULL) && (valid->num_room == 1) && !IsEmptyStr(valid->recp_orgroom)) {
623 CM_SetField(msg, eenVelopeTo, valid->recp_orgroom);
627 msgnum = CtdlSubmitMsg(msg, valid, "");
629 cprintf("%ld\n", msgnum);
631 if (StrLength(CC->StatusMessage) > 0) {
632 cprintf("%s\n", ChrPtr(CC->StatusMessage));
634 else if (msgnum >= 0L) {
635 client_write(HKEY("Message accepted.\n"));
638 client_write(HKEY("Internal error.\n"));
641 if (!CM_IsEmpty(msg, eExclusiveID)) {
642 cprintf("%s\n", msg->cm_fields[eExclusiveID]);
653 free_recipients(valid);
659 // Delete message from current room
660 void cmd_dele(char *args) {
668 extract_token(msgset, args, 0, '|', sizeof msgset);
669 num_msgs = num_tokens(msgset, ',');
671 cprintf("%d Nothing to do.\n", CIT_OK);
675 if (CtdlDoIHavePermissionToDeleteMessagesFromThisRoom() == 0) {
676 cprintf("%d Higher access required.\n", ERROR + HIGHER_ACCESS_REQUIRED);
680 // Build our message set to be moved/copied
681 msgs = malloc(num_msgs * sizeof(long));
682 for (i=0; i<num_msgs; ++i) {
683 extract_token(msgtok, msgset, i, ',', sizeof msgtok);
684 msgs[i] = atol(msgtok);
687 num_deleted = CtdlDeleteMessages(CC->room.QRname, msgs, num_msgs, "");
691 cprintf("%d %d message%s deleted.\n", CIT_OK, num_deleted, ((num_deleted != 1) ? "s" : ""));
694 cprintf("%d Message not found.\n", ERROR + MESSAGE_NOT_FOUND);
699 // move or copy a message to another room
700 void cmd_move(char *args) {
706 char targ[ROOMNAMELEN];
707 struct ctdlroom qtemp;
714 extract_token(msgset, args, 0, '|', sizeof msgset);
715 num_msgs = num_tokens(msgset, ',');
717 cprintf("%d Nothing to do.\n", CIT_OK);
721 extract_token(targ, args, 1, '|', sizeof targ);
722 convert_room_name_macros(targ, sizeof targ);
723 targ[ROOMNAMELEN - 1] = 0;
724 is_copy = extract_int(args, 2);
726 if (CtdlGetRoom(&qtemp, targ) != 0) {
727 cprintf("%d '%s' does not exist.\n", ERROR + ROOM_NOT_FOUND, targ);
731 if (!strcasecmp(qtemp.QRname, CC->room.QRname)) {
732 cprintf("%d Source and target rooms are the same.\n", ERROR + ALREADY_EXISTS);
736 CtdlGetUser(&CC->user, CC->curr_user);
737 CtdlRoomAccess(&qtemp, &CC->user, &ra, NULL);
739 // Check for permission to perform this operation.
740 // Remember: "CC->room" is source, "qtemp" is target.
743 // Admins can move/copy
744 if (CC->user.axlevel >= AxAideU) permit = 1;
746 // Room aides can move/copy
747 if (CC->user.usernum == CC->room.QRroomaide) permit = 1;
749 // Permit move/copy from personal rooms
750 if ((CC->room.QRflags & QR_MAILBOX)
751 && (qtemp.QRflags & QR_MAILBOX)) permit = 1;
753 // Permit only copy from public to personal room
755 && (!(CC->room.QRflags & QR_MAILBOX))
756 && (qtemp.QRflags & QR_MAILBOX)
761 // Permit message removal from collaborative delete rooms
762 if (CC->room.QRflags2 & QR2_COLLABDEL) permit = 1;
764 // Users allowed to post into the target room may move into it too.
765 if ((CC->room.QRflags & QR_MAILBOX) &&
766 (qtemp.QRflags & UA_POSTALLOWED)) permit = 1;
768 // User must have access to target room
769 if (!(ra & UA_KNOWN)) permit = 0;
772 cprintf("%d Higher access required.\n", ERROR + HIGHER_ACCESS_REQUIRED);
776 // Build our message set to be moved/copied
777 msgs = malloc(num_msgs * sizeof(long));
778 for (i=0; i<num_msgs; ++i) {
779 extract_token(msgtok, msgset, i, ',', sizeof msgtok);
780 msgs[i] = atol(msgtok);
784 err = CtdlSaveMsgPointersInRoom(targ, msgs, num_msgs, 1, NULL, 0);
786 cprintf("%d Cannot store message(s) in %s: error %d\n", err, targ, err);
791 // Now delete the message from the source room, if this is a 'move' rather than a 'copy' operation.
793 CtdlDeleteMessages(CC->room.QRname, msgs, num_msgs, "");
797 cprintf("%d Message(s) %s.\n", CIT_OK, (is_copy ? "copied" : "moved") );
801 // Initialization function, called from modules_init.c
802 char *ctdl_module_init_ctdl_message(void) {
804 CtdlRegisterProtoHook(cmd_msgs, "MSGS", "Output a list of messages in the current room");
805 CtdlRegisterProtoHook(cmd_msg0, "MSG0", "Output a message in plain text format");
806 CtdlRegisterProtoHook(cmd_msg2, "MSG2", "Output a message in RFC822 format");
807 CtdlRegisterProtoHook(cmd_msg4, "MSG4", "Output a message in the client's preferred format");
808 CtdlRegisterProtoHook(cmd_msgp, "MSGP", "Select preferred format for MSG4 output");
809 CtdlRegisterProtoHook(cmd_opna, "OPNA", "Open an attachment for download");
810 CtdlRegisterProtoHook(cmd_dlat, "DLAT", "Download an attachment");
811 CtdlRegisterProtoHook(cmd_ent0, "ENT0", "Enter a message");
812 CtdlRegisterProtoHook(cmd_dele, "DELE", "Delete a message");
813 CtdlRegisterProtoHook(cmd_move, "MOVE", "Move or copy a message to another room");
816 // return a module name for the log
817 return "ctdl_message";