From 1ad55ddf7677eccfc0146a10c0d3fc8945b304cf Mon Sep 17 00:00:00 2001 From: "Mohammad Reza Tayyebi (on Dell Latitude)" Date: Thu, 1 Jan 2026 15:48:47 +0400 Subject: [PATCH] feat(api): Add SMTP over HTTP REST API endpoint Adds a new API endpoint POST /api/v1/send/email for sending emails programmatically via SMTP. Features: - Send emails with plain text and/or HTML body - Support for CC, BCC, and reply-to addresses - Base64 encoded file attachments - Configurable SMTP host, port, and authentication - Full validation of email addresses - Proper error handling with descriptive messages Files changed: - data/web/inc/functions.inc.php: Added smtp_api() function - data/web/json_api.php: Added 'send' action handler - data/web/api/openapi.yaml: Added endpoint documentation - data/web/lang/lang.en-gb.json: Added error/success messages --- data/web/api/openapi.yaml | 148 +++++++++++++++ data/web/inc/functions.inc.php | 320 +++++++++++++++++++++++++++++++++ data/web/json_api.php | 56 ++++++ data/web/lang/lang.en-gb.json | 12 ++ 4 files changed, 536 insertions(+) diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index f207ee6a1..231441bf6 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -6047,6 +6047,152 @@ paths: ignore_ssl_error: true summary: Edit external Identity Provider + /api/v1/send/email: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - type: success + msg: + - smtp_mail_sent + - sender@domain.tld + - recipient@domain.tld + schema: + properties: + type: + enum: + - success + - error + type: string + msg: + items: {} + type: array + type: object + description: OK + "400": + content: + application/json: + schema: + properties: + type: + type: string + example: error + msg: + type: array + items: {} + type: object + description: Bad Request - Validation error or SMTP failure + tags: + - SMTP + description: >- + Send an email via SMTP using the mailcow SMTP API. This endpoint allows + you to send emails programmatically with support for HTML content, + attachments, CC, BCC, and custom SMTP settings. + + + **Authentication**: Requires a read-write API key. + + + **Note**: When using authenticated SMTP (port 587 with STARTTLS or port 465 with SSL), + provide the password field. For unauthenticated internal sending (port 25), + the password can be omitted. + operationId: Send email + requestBody: + content: + application/json: + schema: + example: + from: sender@domain.tld + to: + - recipient@domain.tld + subject: Test Subject + body: This is the plain text body + html_body: "

Hello

This is HTML content

" + cc: + - cc@domain.tld + bcc: + - bcc@domain.tld + reply_to: reply@domain.tld + attachments: + - filename: document.pdf + content: base64encodedcontent + content_type: application/pdf + smtp_host: postfix-mailcow + smtp_port: 25 + smtp_user: sender@domain.tld + password: yourpassword + properties: + from: + description: Sender email address + type: string + to: + description: Array of recipient email addresses + type: array + items: + type: string + subject: + description: Email subject + type: string + body: + description: Plain text email body + type: string + html_body: + description: HTML email body (optional) + type: string + cc: + description: Array of CC email addresses (optional) + type: array + items: + type: string + bcc: + description: Array of BCC email addresses (optional) + type: array + items: + type: string + reply_to: + description: Reply-to email address (optional) + type: string + attachments: + description: Array of attachments with base64 encoded content (optional) + type: array + items: + type: object + properties: + filename: + type: string + description: Attachment filename + content: + type: string + description: Base64 encoded file content + content_type: + type: string + description: MIME content type + smtp_host: + description: SMTP server host (default postfix-mailcow) + type: string + smtp_port: + description: SMTP server port (default 25 for internal, use 587 for authenticated) + type: integer + smtp_user: + description: SMTP username (default same as from) + type: string + password: + description: SMTP password for authentication + type: string + required: + - from + - to + - subject + - body + type: object + summary: Send email via SMTP + tags: - name: Domains description: You can create antispam whitelist and blacklist policies @@ -6094,3 +6240,5 @@ tags: description: Manage Cross-Origin Resource Sharing (CORS) settings - name: Identity Provider description: Manage external Identity Provider settings + - name: SMTP + description: Send emails programmatically via SMTP diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 1947ec465..4bc1426f6 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -3387,6 +3387,326 @@ function reset_password($action, $data = null) { break; } } +function smtp_api($action, $data) { + global $pdo; + $_data_log = $data; + // Mask password in logs + if (isset($_data_log['password'])) { + $_data_log['password'] = '*'; + } + + switch ($action) { + case 'send': + // Validate required fields + $from = isset($data['from']) ? $data['from'] : ''; + $to = isset($data['to']) ? $data['to'] : array(); + $subject = isset($data['subject']) ? $data['subject'] : ''; + $body = isset($data['body']) ? $data['body'] : ''; + + // Optional fields + $html_body = isset($data['html_body']) ? $data['html_body'] : null; + $cc = isset($data['cc']) ? $data['cc'] : array(); + $bcc = isset($data['bcc']) ? $data['bcc'] : array(); + $reply_to = isset($data['reply_to']) ? $data['reply_to'] : null; + $attachments = isset($data['attachments']) ? $data['attachments'] : array(); + + // SMTP settings - default to port 25 for unauthenticated internal sending + $smtp_host = isset($data['smtp_host']) ? $data['smtp_host'] : 'postfix-mailcow'; + $smtp_port = isset($data['smtp_port']) ? intval($data['smtp_port']) : 25; + $smtp_user = isset($data['smtp_user']) ? $data['smtp_user'] : $from; + $smtp_pass = isset($data['password']) ? $data['password'] : ''; + + // Validate from address + if (!filter_var($from, FILTER_VALIDATE_EMAIL)) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'smtp_invalid_from' + ); + return false; + } + + // Check sender authorization (admins can bypass, others must be authorized) + if ($_SESSION['mailcow_cc_role'] != 'admin') { + $username = $_SESSION['mailcow_cc_username']; + $sender_authorized = false; + + // Check if from address is the user's own mailbox + if (strtolower($from) == strtolower($username)) { + $sender_authorized = true; + } + + if (!$sender_authorized) { + // Check if from address is in user's aliases (goto contains the username) + $stmt = $pdo->prepare("SELECT `address` FROM `alias` WHERE `goto` REGEXP :goto AND `address` = :from_address"); + $stmt->execute(array( + ':goto' => '(^|,)' . preg_quote($username, '/') . '($|,)', + ':from_address' => $from + )); + if ($stmt->rowCount() > 0) { + $sender_authorized = true; + } + } + + if (!$sender_authorized) { + // Check sender_acl for specific address + $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `send_as` = :from_address"); + $stmt->execute(array( + ':username' => $username, + ':from_address' => $from + )); + if ($stmt->rowCount() > 0) { + $sender_authorized = true; + } + } + + if (!$sender_authorized) { + // Check sender_acl for domain wildcard (@domain.tld) + $from_domain = substr(strrchr($from, '@'), 1); + $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `send_as` = :domain_wildcard"); + $stmt->execute(array( + ':username' => $username, + ':domain_wildcard' => '@' . $from_domain + )); + if ($stmt->rowCount() > 0) { + $sender_authorized = true; + } + } + + if (!$sender_authorized) { + // Check sender_acl for global wildcard (*) + $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `send_as` = '*'"); + $stmt->execute(array(':username' => $username)); + if ($stmt->rowCount() > 0) { + $sender_authorized = true; + } + } + + if (!$sender_authorized) { + // Check external sender aliases + $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `external` = '1' AND `send_as` = :from_address"); + $stmt->execute(array( + ':username' => $username, + ':from_address' => $from + )); + if ($stmt->rowCount() > 0) { + $sender_authorized = true; + } + } + + if (!$sender_authorized) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('smtp_unauthorized_sender', htmlspecialchars($from)) + ); + return false; + } + } + + // Validate to addresses + if (empty($to)) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'smtp_missing_recipients' + ); + return false; + } + + // Ensure to is an array + if (!is_array($to)) { + $to = array($to); + } + + foreach ($to as $recipient) { + if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('smtp_invalid_recipient', htmlspecialchars($recipient)) + ); + return false; + } + } + + // Validate subject + if (empty($subject)) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'smtp_empty_subject' + ); + return false; + } + + // Validate body + if (empty($body) && empty($html_body)) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'smtp_empty_body' + ); + return false; + } + + // Validate CC addresses if provided + if (!empty($cc)) { + if (!is_array($cc)) { + $cc = array($cc); + } + foreach ($cc as $cc_addr) { + if (!filter_var($cc_addr, FILTER_VALIDATE_EMAIL)) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('smtp_invalid_cc', htmlspecialchars($cc_addr)) + ); + return false; + } + } + } + + // Validate BCC addresses if provided + if (!empty($bcc)) { + if (!is_array($bcc)) { + $bcc = array($bcc); + } + foreach ($bcc as $bcc_addr) { + if (!filter_var($bcc_addr, FILTER_VALIDATE_EMAIL)) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('smtp_invalid_bcc', htmlspecialchars($bcc_addr)) + ); + return false; + } + } + } + + // Validate reply-to if provided + if (!empty($reply_to) && !filter_var($reply_to, FILTER_VALIDATE_EMAIL)) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => 'smtp_invalid_reply_to' + ); + return false; + } + + try { + ini_set('max_execution_time', 0); + ini_set('max_input_time', 0); + + $mail = new PHPMailer(true); + $mail->Timeout = 30; + $mail->SMTPOptions = array( + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ) + ); + $mail->isSMTP(); + $mail->Host = $smtp_host; + $mail->Port = $smtp_port; + $mail->CharSet = 'UTF-8'; + $mail->XMailer = 'mailcow SMTP API'; + + // Enable authentication if password is provided + if (!empty($smtp_pass)) { + $mail->SMTPAuth = true; + $mail->Username = $smtp_user; + $mail->Password = $smtp_pass; + if ($smtp_port == 465) { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } elseif ($smtp_port == 587) { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + } else { + $mail->SMTPAuth = false; + } + + // Set sender + $mail->setFrom($from); + + // Add recipients + foreach ($to as $recipient) { + $mail->addAddress($recipient); + } + + // Add CC + if (!empty($cc)) { + foreach ($cc as $cc_addr) { + $mail->addCC($cc_addr); + } + } + + // Add BCC + if (!empty($bcc)) { + foreach ($bcc as $bcc_addr) { + $mail->addBCC($bcc_addr); + } + } + + // Set reply-to + if (!empty($reply_to)) { + $mail->addReplyTo($reply_to); + } + + // Set subject + $mail->Subject = $subject; + + // Set body + if (!empty($html_body)) { + $mail->isHTML(true); + $mail->Body = $html_body; + $mail->AltBody = $body; + } else { + $mail->Body = $body; + } + + // Add attachments + if (!empty($attachments) && is_array($attachments)) { + foreach ($attachments as $attachment) { + if (isset($attachment['content']) && isset($attachment['filename'])) { + $content = base64_decode($attachment['content']); + if ($content === false) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('smtp_invalid_attachment', htmlspecialchars($attachment['filename'])) + ); + return false; + } + $content_type = isset($attachment['content_type']) ? $attachment['content_type'] : 'application/octet-stream'; + $mail->addStringAttachment($content, $attachment['filename'], 'base64', $content_type); + } + } + } + + // Send + $mail->send(); + + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('smtp_mail_sent', htmlspecialchars($from), implode(', ', $to)) + ); + return true; + + } catch (Exception $e) { + $_SESSION['return'][] = array( + 'type' => 'error', + 'log' => array(__FUNCTION__, $action, $_data_log), + 'msg' => array('smtp_error', htmlspecialchars($mail->ErrorInfo)) + ); + return false; + } + break; + } + return false; +} function clear_session(){ session_regenerate_id(true); session_unset(); diff --git a/data/web/json_api.php b/data/web/json_api.php index 565246e58..91d061e18 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -2018,6 +2018,62 @@ if (isset($_GET['query'])) { exit(); } break; + case "send": + if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username'])) { + http_response_code(403); + echo json_encode(array( + 'type' => 'error', + 'msg' => 'API read/write access denied' + )); + exit(); + } + function process_send_return($return) { + $generic_failure = json_encode(array( + 'type' => 'error', + 'msg' => 'Failed to send email' + )); + $generic_success = json_encode(array( + 'type' => 'success', + 'msg' => 'Email sent successfully' + )); + if ($return === false) { + http_response_code(400); + echo isset($_SESSION['return']) ? json_encode($_SESSION['return']) : $generic_failure; + } + else { + echo isset($_SESSION['return']) ? json_encode($_SESSION['return']) : $generic_success; + } + } + if (!isset($_POST['attr'])) { + echo $request_incomplete; + exit; + } + else { + $attr = (array)json_decode($_POST['attr'], true); + unset($attr['csrf_token']); + } + // only allow POST requests + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + http_response_code(405); + echo json_encode(array( + 'type' => 'error', + 'msg' => 'only POST method is allowed' + )); + exit(); + } + switch ($category) { + case "email": + process_send_return(smtp_api('send', $attr)); + break; + default: + http_response_code(404); + echo json_encode(array( + 'type' => 'error', + 'msg' => 'route not found' + )); + exit(); + } + break; // return no route found if no case is matched default: http_response_code(404); diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 1e8525957..10bff8748 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -542,6 +542,17 @@ "tls_policy_map_dest_invalid": "Policy destination is invalid", "tls_policy_map_entry_exists": "A TLS policy map entry \"%s\" exists", "tls_policy_map_parameter_invalid": "Policy parameter is invalid", + "smtp_invalid_from": "Invalid sender email address", + "smtp_unauthorized_sender": "You are not authorized to send as %s", + "smtp_missing_recipients": "At least one recipient is required", + "smtp_invalid_recipient": "Invalid recipient email address: %s", + "smtp_empty_subject": "Email subject cannot be empty", + "smtp_empty_body": "Email body cannot be empty", + "smtp_invalid_cc": "Invalid CC email address: %s", + "smtp_invalid_bcc": "Invalid BCC email address: %s", + "smtp_invalid_reply_to": "Invalid reply-to email address", + "smtp_invalid_attachment": "Invalid base64 attachment: %s", + "smtp_error": "SMTP error: %s", "to_invalid": "Recipient must not be empty", "totp_verification_failed": "TOTP verification failed", "transport_dest_exists": "Transport destination \"%s\" exists", @@ -1198,6 +1209,7 @@ "saved_settings": "Saved settings", "settings_map_added": "Added settings map entry", "settings_map_removed": "Removed settings map ID %s", + "smtp_mail_sent": "Email sent successfully from %s to %s", "sogo_profile_reset": "SOGo profile for user %s was reset", "template_added": "Added template %s", "template_modified": "Changes to template %s have been saved",