mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Merge pull request #3205 from modoboa/feature/postfix_autoreply
Postfix autoreply merged back to core repo
This commit is contained in:
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 7.1 KiB |
@@ -644,3 +644,31 @@ Hash the file using the following command::
|
||||
Finally, reload postfix::
|
||||
|
||||
$ service postfix reload
|
||||
|
||||
************************
|
||||
Auto-reply using Postfix
|
||||
************************
|
||||
|
||||
The user that executes the autoreply script needs to access
|
||||
:file:`settings.py`. You must apply proper permissions on this file. For
|
||||
example, if :file:`settings.py` belongs to ``www-data:www-data``, you can add
|
||||
the ``vmail`` user to the ``www-data`` group and set the read permission
|
||||
for the group.
|
||||
|
||||
To make Postfix use this feature, you need to update your
|
||||
configuration file as follows:
|
||||
|
||||
``/etc/postfix/master.cf``::
|
||||
|
||||
autoreply unix - n n - - pipe
|
||||
flags= user=vmail:<group> argv=python <modoboa_site>/manage.py autoreply $sender $mailbox
|
||||
|
||||
Replace ``<driver>`` by the name of the database you
|
||||
use. ``<modoboa_site>`` is the path of your Modoboa instance.
|
||||
|
||||
.. note::
|
||||
|
||||
Auto-reply messages are just sent once per sender for a
|
||||
pre-defined time period. By default, this period is equal to 1 day
|
||||
(86400s), you can adjust this value by modifying the **Automatic
|
||||
reply timeout** parameter available in the online panel.
|
||||
|
||||
@@ -32,9 +32,6 @@ And it is extensible by nature and comes with a lot of additional extensions:
|
||||
|`modoboa-pfxadmin-migrate |A tool to migrate from |https://github.com/modoboa/modoboa-pfxadmin-migrate |
|
||||
|<https://github.com/modoboa/modoboa-pfxadmin-migrate>`_ |Postfixadmin | |
|
||||
+---------------------------------------------------------+-------------------------+------------------------------------------------------+
|
||||
|`modoboa-postfix-autoreply |Away message editor |https://modoboa-postfix-autoreply.readthedocs.io |
|
||||
|<https://github.com/modoboa/modoboa-postfix-autoreply>`_ |(postfix compatible) | |
|
||||
+---------------------------------------------------------+-------------------------+------------------------------------------------------+
|
||||
|`modoboa-radicale |A frontend for `Radicale |https://modoboa-radicale.readthedocs.io |
|
||||
|<https://github.com/modoboa/modoboa-radicale>`_ |<http://radicale.org>`_ | |
|
||||
+---------------------------------------------------------+-------------------------+------------------------------------------------------+
|
||||
|
||||
@@ -131,6 +131,47 @@ Rebuild Virtual Environment
|
||||
Specific instructions
|
||||
*********************
|
||||
|
||||
2.3.0
|
||||
=====
|
||||
|
||||
.. warning::
|
||||
|
||||
For this particular version, it is really important that you apply
|
||||
new migrations **AFTER** the following instructions. If you don't,
|
||||
you'll get into trouble...
|
||||
|
||||
The ``modoboa-postfix-autoreply`` plugin has been merged into the core.
|
||||
|
||||
In :file:`settings.py` file, add ``'modoboa.postfix_autoreply'`` to ``MODOBOA_APPS``:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
MODOBOA_APPS = (
|
||||
'modoboa',
|
||||
'modoboa.core',
|
||||
'modoboa.lib',
|
||||
'modoboa.admin',
|
||||
'modoboa.transport',
|
||||
'modoboa.relaydomains',
|
||||
'modoboa.limits',
|
||||
'modoboa.parameters',
|
||||
'modoboa.dnstools',
|
||||
'modoboa.policyd',
|
||||
'modoboa.maillog',
|
||||
'modoboa.dmarc',
|
||||
'modoboa.pdfcredentials',
|
||||
'modoboa.imap_migration',
|
||||
'modoboa.postfix_autoreply',
|
||||
)
|
||||
|
||||
And remove any reference to ``'modoboa_postfix_autoreply'`` in this same variable.
|
||||
|
||||
After upgrading modoboa, run the following commands from your virtual environment:
|
||||
|
||||
.. sourcecode:: bash
|
||||
|
||||
> python manage.py rename_app modoboa_postfix_autoreply postfix_autoreply
|
||||
|
||||
|
||||
2.2.3
|
||||
=====
|
||||
|
||||
@@ -44,4 +44,10 @@ export default {
|
||||
deleteAPIToken() {
|
||||
return repository.delete(`${resource}/api_token/`)
|
||||
},
|
||||
getARMessage() {
|
||||
return repository.get(`${resource}/armessage/`)
|
||||
},
|
||||
setARMessage(data) {
|
||||
return repository.put(`${resource}/armessage/`, data)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<v-card v-if="!token" flat>
|
||||
<v-card-text>
|
||||
{{ $gettext('Your API token has not been generated yet.') }}
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="flat"
|
||||
:loading="loading"
|
||||
@click="generateKey"
|
||||
>
|
||||
{{ $gettext('Generate') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
<v-card v-else flat>
|
||||
<v-card-text>
|
||||
<label class="m-label">
|
||||
{{ $gettext('Your API token is:') }}
|
||||
</label>
|
||||
<code class="ml-5 mr-5">
|
||||
{{ token }}
|
||||
</code>
|
||||
<v-btn
|
||||
icon="mdi-clipboard-plus"
|
||||
density="compact"
|
||||
:title="$gettext('Copy token to clipboard')"
|
||||
variant="text"
|
||||
@click="copyToClipboard()"
|
||||
>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
density="compact"
|
||||
color="error"
|
||||
variant="text"
|
||||
:title="$gettext('Delete token')"
|
||||
@click="openDeletionDialog"
|
||||
>
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-alert type="info" variant="tonal" border="start" class="mt-4">
|
||||
{{ $gettext('A documentation of the API is available') }}
|
||||
<a :href="apiDocUrl" target="_blank">{{ $gettext('here') }}</a
|
||||
>.
|
||||
</v-alert>
|
||||
<ConfirmDialog ref="confirm" @agree="deleteToken" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import accountApi from '@/api/account'
|
||||
import { useBusStore } from '@/stores'
|
||||
import ConfirmDialog from '@/components/tools/ConfirmDialog.vue'
|
||||
import { ref, computed, inject, onMounted } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
|
||||
const $config = inject('$config')
|
||||
|
||||
const apiDocUrl = computed(() => $config.API_DOC_URL)
|
||||
|
||||
const loading = ref(false)
|
||||
const token = ref(null)
|
||||
const confirm = ref()
|
||||
|
||||
function copyToClipboard() {
|
||||
navigator.clipboard.writeText(token.value)
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('API token copied to your clipboard'),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
function generateKey() {
|
||||
loading.value = true
|
||||
accountApi
|
||||
.createAPIToken()
|
||||
.then((resp) => {
|
||||
token.value = resp.data.token
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('API token created'),
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
.finally(() => (loading.value = false))
|
||||
}
|
||||
|
||||
function openDeletionDialog() {
|
||||
confirm.value.open(
|
||||
$gettext('Warning'),
|
||||
$gettext('You are about to delete your API access token'),
|
||||
{
|
||||
color: 'error',
|
||||
cancelLabel: $gettext('Cancel'),
|
||||
agreeLabel: $gettext('Proceed'),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteToken() {
|
||||
await accountApi.deleteAPIToken()
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('API token deleted'),
|
||||
type: 'success',
|
||||
})
|
||||
token.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
accountApi.getAPIToken().then((resp) => {
|
||||
token.value = resp.data.token
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<v-form ref="formRef" @submit.prevent="submit">
|
||||
<v-checkbox
|
||||
v-model="form.enabled"
|
||||
:label="$gettext('Enabled')"
|
||||
color="primary"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
/>
|
||||
<label class="m-label">{{ $gettext('Subject') }}</label>
|
||||
<v-text-field
|
||||
v-model="form.subject"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<label class="m-label">{{ $gettext('Content') }}</label>
|
||||
<v-textarea
|
||||
v-model="form.content"
|
||||
:hint="$gettext('The content of your answer. You can use the following variables, which will be automatically replaced by the appropriate value: %(name)s, %(fromdate)s, %(untildate)s')"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
density="compact"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="m-label">{{ $gettext('From') }}</label>
|
||||
<v-text-field
|
||||
v-model="form.fromdate"
|
||||
:hint="$gettext('Activate your auto reply from this date')"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
type="datetime-local"
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="m-label">{{ $gettext('Until') }}</label>
|
||||
<v-text-field
|
||||
v-model="form.untildate"
|
||||
:hint="$gettext('Activate your auto reply until this date')"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
type="datetime-local"
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
<v-btn color="success" type="submit" :loading="loading">
|
||||
{{ $gettext('Update auto-reply') }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useBusStore } from '@/stores'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import accountApi from '@/api/account'
|
||||
import rules from '@/plugins/rules'
|
||||
|
||||
const busStore = useBusStore()
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
const form = ref({})
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
accountApi.setARMessage(form.value).then(() => {
|
||||
busStore.displayNotification({ msg: $gettext('Auto-reply message updated') })
|
||||
loading.value = false
|
||||
}).catch(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
accountApi.getARMessage().then(resp => {
|
||||
form.value = resp.data
|
||||
if (form.value.fromdate) {
|
||||
form.value.fromdate = form.value.fromdate.slice(0, -4)
|
||||
}
|
||||
if (form.value.untildate) {
|
||||
form.value.untildate = form.value.untildate.slice(0, -4)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,4 +1,3 @@
|
||||
Ac
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
@@ -57,11 +56,12 @@ Ac
|
||||
</template>
|
||||
|
||||
<script setup lang="js">
|
||||
// import { bus } from '@/main'
|
||||
import { ref } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import senderAddresses from '@/api/senderAddresses'
|
||||
|
||||
const busStore = useBusStore()
|
||||
const { $gettext } = useGettext()
|
||||
const props = defineProps({ account: { type: Object, default: null } })
|
||||
|
||||
@@ -94,14 +94,14 @@ function submit() {
|
||||
fetchSenderAddresses()
|
||||
newAddress.value = null
|
||||
hideAddBtn.value = false
|
||||
// bus.$emit('notification', { msg: this.$gettext('Sender address added') })
|
||||
busStore.displayNotification({ msg: $gettext('Sender address added') })
|
||||
})
|
||||
}
|
||||
|
||||
function deleteSenderAddress(senderAddress) {
|
||||
senderAddresses.delete(senderAddress.pk).then(() => {
|
||||
fetchSenderAddresses()
|
||||
// bus.$emit('notification', { msg: this.$gettext('Sender address deleted') })
|
||||
busStore.displayNotification({ msg: $gettext('Sender address deleted') })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ async function authenticate() {
|
||||
authStore
|
||||
.login(payload)
|
||||
.then(() => {
|
||||
router.push({ name: 'Dashboard' })
|
||||
if (authStore.authUser.role === 'SimpleUsers') {
|
||||
router.push({ name: 'AccountSettings' })
|
||||
} else {
|
||||
router.push({ name: 'Dashboard' })
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
loading.value = false
|
||||
|
||||
@@ -183,8 +183,8 @@ const mainMenuItems = [
|
||||
{
|
||||
text: $gettext('Dashboard'),
|
||||
to: { name: 'Dashboard' },
|
||||
icon: 'mdi-view-dashboard-outline',
|
||||
exact: true,
|
||||
icon: 'mdi-view-dashboard-outline'
|
||||
},
|
||||
{
|
||||
text: $gettext('Domains'),
|
||||
@@ -253,48 +253,22 @@ const mainMenuItems = [
|
||||
text: $gettext('Information'),
|
||||
roles: ['SuperAdmins'],
|
||||
to: { name: 'Information' },
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
const userSettingsMenuItems = [
|
||||
{
|
||||
text: $gettext('API'),
|
||||
roles: ['SuperAdmins'],
|
||||
to: { name: 'APISetup' },
|
||||
icon: 'mdi-api',
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
text: $gettext('Profile'),
|
||||
to: { name: 'UserProfile' },
|
||||
icon: 'mdi-account-circle-outline',
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
text: $gettext('Security'),
|
||||
to: { name: 'UserSecurity' },
|
||||
icon: 'mdi-lock-outline',
|
||||
exact: true,
|
||||
},
|
||||
// {
|
||||
// text: $gettext('Preferences'),
|
||||
// icon: 'mdi-tune',
|
||||
// exact: true
|
||||
// },
|
||||
{
|
||||
text: $gettext('Forward'),
|
||||
condition: () => authUser.value.mailbox !== null,
|
||||
to: { name: 'UserForward' },
|
||||
icon: 'mdi-forward',
|
||||
exact: true,
|
||||
},
|
||||
text: $gettext('Settings'),
|
||||
to: { name: 'AccountSettings' },
|
||||
icon: 'mdi-cog'
|
||||
}
|
||||
]
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
text: $gettext('Profile'),
|
||||
text: $gettext('Account'),
|
||||
icon: 'mdi-account-circle-outline',
|
||||
to: { name: 'UserProfile' },
|
||||
to: { name: 'AccountSettings' },
|
||||
click: () => null,
|
||||
},
|
||||
{
|
||||
@@ -304,6 +278,14 @@ const userMenuItems = [
|
||||
},
|
||||
]
|
||||
|
||||
if (authUser.value.role !== 'SimpleUsers') {
|
||||
userMenuItems.unshift({
|
||||
text: $gettext('Admin'),
|
||||
to: { name: 'Dashboard' },
|
||||
icon: 'mdi-view-dashboard-outline'
|
||||
})
|
||||
}
|
||||
|
||||
function displayMenuItem(item) {
|
||||
if (isAuthenticated.value) {
|
||||
const condition =
|
||||
|
||||
+254
-180
File diff suppressed because it is too large
Load Diff
+247
-180
File diff suppressed because it is too large
Load Diff
+254
-180
File diff suppressed because it is too large
Load Diff
+247
-180
File diff suppressed because it is too large
Load Diff
+254
-180
File diff suppressed because it is too large
Load Diff
+254
-180
File diff suppressed because it is too large
Load Diff
+252
-186
File diff suppressed because it is too large
Load Diff
+328
-299
File diff suppressed because it is too large
Load Diff
+254
-180
File diff suppressed because it is too large
Load Diff
+247
-180
File diff suppressed because it is too large
Load Diff
+245
-179
File diff suppressed because it is too large
Load Diff
+247
-180
File diff suppressed because it is too large
Load Diff
+247
-180
File diff suppressed because it is too large
Load Diff
+247
-180
File diff suppressed because it is too large
Load Diff
+254
-180
File diff suppressed because it is too large
Load Diff
+252
-186
File diff suppressed because it is too large
Load Diff
+252
-180
File diff suppressed because it is too large
Load Diff
+247
-180
File diff suppressed because it is too large
Load Diff
@@ -251,38 +251,17 @@ const routes = [
|
||||
{
|
||||
path: '/account',
|
||||
component: () => import('@/layouts/dashboard/DashboardLayout.vue'),
|
||||
meta: { layout: 'account' },
|
||||
meta: {
|
||||
layout: 'account',
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'api',
|
||||
name: 'APISetup',
|
||||
component: () => import('@/views/account/APISetupView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['SuperAdmins'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'UserProfile',
|
||||
component: () => import('@/views/account/ProfileView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
name: 'UserSecurity',
|
||||
component: () => import('@/views/account/SecurityView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'forward',
|
||||
name: 'UserForward',
|
||||
component: () => import('@/views/account/ForwardView.vue'),
|
||||
path: ':tab?',
|
||||
name: 'AccountSettings',
|
||||
component: () => import('@/views/account/SettingsView.vue'),
|
||||
meta: {
|
||||
layout: 'account',
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import gettext from '@/plugins/gettext'
|
||||
|
||||
import repository from '@/api/repository'
|
||||
@@ -13,6 +13,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const authUser = ref({})
|
||||
const isAuthenticated = ref(false)
|
||||
|
||||
const userHasMailbox = computed(() => {
|
||||
return authUser.value.mailbox !== null
|
||||
})
|
||||
|
||||
async function fetchUser() {
|
||||
return accountApi.getMe().then((resp) => {
|
||||
gettext.current = resp.data.language
|
||||
@@ -41,7 +45,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const cookie = Cookies.withAttributes(cookiesAttributes)
|
||||
cookie.set('token', resp.data.access)
|
||||
cookie.set('refreshToken', resp.data.refresh)
|
||||
initialize()
|
||||
return initialize()
|
||||
}
|
||||
async function $reset() {
|
||||
delete repository.defaults.headers.common.Authorization
|
||||
@@ -87,5 +91,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
$reset,
|
||||
updateAccount,
|
||||
finalizeTFASetup,
|
||||
userHasMailbox,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title>{{ $gettext('Account settings') }}</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<div class="pa-4">
|
||||
<v-tabs
|
||||
v-model="tab"
|
||||
color="primary"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-tab value="profile">
|
||||
{{ $gettext('Profile') }}
|
||||
</v-tab>
|
||||
<v-tab value="security">
|
||||
{{ $gettext('Security') }}
|
||||
</v-tab>
|
||||
<v-tab value="forward" v-if="authStore.userHasMailbox">
|
||||
{{ $gettext('Forward') }}
|
||||
</v-tab>
|
||||
<v-tab value="autoreply" v-if="authStore.userHasMailbox">
|
||||
{{ $gettext('Auto-reply message') }}
|
||||
</v-tab>
|
||||
<v-tab value="api" v-if="authStore.authUser.role === 'SuperAdmins'">
|
||||
{{ $gettext('API access') }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<v-window v-model="tab">
|
||||
<v-window-item value="profile">
|
||||
<ProfileForm />
|
||||
</v-window-item>
|
||||
<v-window-item value="security">
|
||||
<TwoFactorAuthForm />
|
||||
</v-window-item>
|
||||
<v-window-item value="forward" v-if="authStore.userHasMailbox">
|
||||
<ForwardForm />
|
||||
</v-window-item>
|
||||
<v-window-item value="autoreply" v-if="authStore.userHasMailbox">
|
||||
<AutoReplyForm />
|
||||
</v-window-item>
|
||||
<v-window-item value="api" v-if="authStore.authUser.role === 'SuperAdmins'">
|
||||
<APISetupForm />
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import ProfileForm from '@/components/account/ProfileForm.vue'
|
||||
import TwoFactorAuthForm from '@/components/account/TwoFactorAuthForm.vue'
|
||||
import ForwardForm from '@/components/account/ForwardForm.vue'
|
||||
import AutoReplyForm from '@/components/account/AutoReplyForm.vue'
|
||||
import APISetupForm from '@/components/account/APISetupForm.vue'
|
||||
import { useAuthStore } from '@/stores'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const tab = ref()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
background-color: #f7f8fa !important;
|
||||
}
|
||||
.v-tabs {
|
||||
background-color: #f7f8fa !important;
|
||||
}
|
||||
</style>
|
||||
@@ -88,6 +88,8 @@ class AccountTestCase(ModoTestCase):
|
||||
"language": "en",
|
||||
"secondary_email": "homer@simpson.com",
|
||||
"phone_number": "+33612345678",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
)
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.id]), values)
|
||||
@@ -203,6 +205,8 @@ class AccountTestCase(ModoTestCase):
|
||||
"is_active": False,
|
||||
"email": "user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.id]), values)
|
||||
internal.refresh_from_db()
|
||||
@@ -228,6 +232,8 @@ class AccountTestCase(ModoTestCase):
|
||||
"is_active": account.is_active,
|
||||
"email": "new_user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
"create_alias_with_old_address": False,
|
||||
"aliases_1": "alias@test.com",
|
||||
}
|
||||
@@ -249,6 +255,8 @@ class AccountTestCase(ModoTestCase):
|
||||
"is_active": account.is_active,
|
||||
"email": "new_user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
"create_alias_with_old_address": False,
|
||||
}
|
||||
url = reverse("admin:account_change", args=[account.pk])
|
||||
@@ -343,6 +351,8 @@ class AccountTestCase(ModoTestCase):
|
||||
account = User.objects.get(username=values["username"])
|
||||
password = account.password
|
||||
values["language"] = "en"
|
||||
values["subject"] = "subject"
|
||||
values["content"] = "content"
|
||||
# Since 'random_password' is still True, a new password should
|
||||
# be generated
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
@@ -367,6 +377,8 @@ class AccountTestCase(ModoTestCase):
|
||||
"is_active": True,
|
||||
"email": "user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
account = User.objects.get(username="user@test.com")
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
@@ -385,6 +397,8 @@ class AccountTestCase(ModoTestCase):
|
||||
"senderaddress": "test@titi.com",
|
||||
"senderaddress_1": "toto@go.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
self.assertEqual(
|
||||
@@ -474,6 +488,8 @@ class AccountTestCase(ModoTestCase):
|
||||
"quota": value,
|
||||
"email": email,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(
|
||||
reverse("admin:account_change", args=[account.id]),
|
||||
@@ -617,6 +633,8 @@ class PermissionsTestCase(ModoTestCase):
|
||||
"email": "user@test.com",
|
||||
"quota_act": True,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
@@ -691,6 +709,8 @@ class PermissionsTestCase(ModoTestCase):
|
||||
"is_active": True,
|
||||
"email": "admin@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[admin.id]), values)
|
||||
self.assertEqual(admin.role, "DomainAdmins")
|
||||
@@ -711,6 +731,8 @@ class PermissionsTestCase(ModoTestCase):
|
||||
"password2": "",
|
||||
"is_active": True,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[self.reseller.pk]), values)
|
||||
self.assertEqual(self.reseller.role, "Resellers")
|
||||
|
||||
@@ -26,6 +26,8 @@ class AliasTestCase(ModoTestCase):
|
||||
"aliases": "toto@test.com",
|
||||
"aliases_1": "titi@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
self.assertEqual(
|
||||
@@ -65,6 +67,8 @@ class AliasTestCase(ModoTestCase):
|
||||
"email": "user@test.com",
|
||||
"aliases": "Toto@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
mb = user.mailbox
|
||||
@@ -96,6 +100,8 @@ class AliasTestCase(ModoTestCase):
|
||||
"is_active": user.is_active,
|
||||
"email": "user@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
|
||||
@@ -236,6 +242,8 @@ class AliasTestCase(ModoTestCase):
|
||||
"is_active": True,
|
||||
"email": user.email,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
|
||||
@@ -264,6 +272,8 @@ class AliasTestCase(ModoTestCase):
|
||||
"aliases": "toto@test.com",
|
||||
"aliases_1": "titi@test2.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
response = self.ajax_post(
|
||||
reverse("admin:account_change", args=[user.id]), values, 400
|
||||
|
||||
@@ -63,6 +63,8 @@ class MailboxOperationTestCase(ModoTestCase):
|
||||
"is_active": True,
|
||||
"email": "admin2@test.com",
|
||||
"language": "en",
|
||||
"subject": "test",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[mb.user.pk]), values)
|
||||
path = "{}/test.com/admin2".format(self.workdir)
|
||||
|
||||
@@ -113,6 +113,9 @@ SETTINGS_SAMPLE = {
|
||||
"imap_migration-enabled_imapmigration": True,
|
||||
"imap_migration-max_sync_accounts": 2,
|
||||
"imap_migration-create_folders": True,
|
||||
"postfix_autoreply-autoreplies_timeout": 86400,
|
||||
"postfix_autoreply-default_subject": "I'm off",
|
||||
"postfix_autoreply-default_content": "I'm off",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ class DomainLimitsTestCase(lib_tests.ModoTestCase):
|
||||
"domains": "test2.com",
|
||||
"domains_1": "test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
self.assertTrue(limit.is_exceeded())
|
||||
@@ -151,6 +153,8 @@ class DomainLimitsTestCase(lib_tests.ModoTestCase):
|
||||
"aliases": "alias@test.com",
|
||||
"aliases_1": "alias1@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
self.assertTrue(limit.is_exceeded())
|
||||
|
||||
@@ -188,6 +188,8 @@ class DomainAdminTestCase(ResourceTestCase):
|
||||
"aliases": "alias1@test.com",
|
||||
"aliases_1": "alias2@test.com",
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
Alias.objects.get(address="alias1@test.com")
|
||||
@@ -269,6 +271,8 @@ class ResellerTestCase(ResourceTestCase):
|
||||
"is_active": user.is_active,
|
||||
"email": user.email,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
resp = self.ajax_post(
|
||||
reverse("admin:account_change", args=[user.id]), values, status=400
|
||||
@@ -355,6 +359,8 @@ class ResellerTestCase(ResourceTestCase):
|
||||
"language": "en",
|
||||
"quota_act": False,
|
||||
"quota": 0,
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[admin.pk]), values, 400)
|
||||
|
||||
@@ -399,6 +405,8 @@ class ResellerTestCase(ResourceTestCase):
|
||||
"mailboxes_limit": 1,
|
||||
"mailbox_aliases_limit": 2,
|
||||
"language": "en",
|
||||
"subject": "subject",
|
||||
"content": "content",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[user.id]), values)
|
||||
self._check_limit("mailboxes", 1, 1)
|
||||
|
||||
+1343
-852
File diff suppressed because it is too large
Load Diff
+1070
-759
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1060
-755
File diff suppressed because it is too large
Load Diff
+1328
-852
File diff suppressed because it is too large
Load Diff
+1370
-869
File diff suppressed because it is too large
Load Diff
+1279
-815
File diff suppressed because it is too large
Load Diff
+1060
-755
File diff suppressed because it is too large
Load Diff
+1386
-862
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1060
-755
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1225
-822
File diff suppressed because it is too large
Load Diff
+1060
-755
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1172
-782
File diff suppressed because it is too large
Load Diff
+1186
-801
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1064
-756
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,7 @@ class EventsTestCase(ModoTestCase):
|
||||
self.assertContains(response, "No document available for this user")
|
||||
|
||||
# Update account
|
||||
values.update({"language": "en"})
|
||||
values.update({"language": "en", "subject": "subject", "content": "content"})
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.pk]), values)
|
||||
self.assertFalse(os.path.exists(fname))
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Away message editor for Modoboa (postfix compatible)."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from pkg_resources import DistributionNotFound, get_distribution
|
||||
|
||||
try:
|
||||
__version__ = get_distribution(__name__).version
|
||||
except DistributionNotFound:
|
||||
# package is not installed
|
||||
pass
|
||||
|
||||
default_app_config = "modoboa_postfix_autoreply.apps.PostfixAutoreplyConfig"
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Autoreply serializers."""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from modoboa.postfix_autoreply import models
|
||||
|
||||
|
||||
class ARMessageSerializer(serializers.ModelSerializer):
|
||||
"""A serializer for ARmessage."""
|
||||
|
||||
class Meta:
|
||||
model = models.ARmessage
|
||||
fields = "__all__"
|
||||
|
||||
def validate(self, data):
|
||||
"""Check dates."""
|
||||
if not data.get("fromdate"):
|
||||
data["fromdate"] = timezone.now()
|
||||
if not data["enabled"]:
|
||||
return data
|
||||
untildate = data.get("untildate")
|
||||
if untildate is not None:
|
||||
if untildate < timezone.now():
|
||||
raise serializers.ValidationError({"untildate": _("This date is over")})
|
||||
elif untildate < data["fromdate"]:
|
||||
raise serializers.ValidationError(
|
||||
{"untildate": _("Must be greater than start date")}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
class AccountARMessageSerializer(ARMessageSerializer):
|
||||
"""Dedicated serializer for account operations."""
|
||||
|
||||
class Meta:
|
||||
model = models.ARmessage
|
||||
fields = ("enabled", "subject", "content", "fromdate", "untildate")
|
||||
@@ -0,0 +1,169 @@
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from modoboa.admin import factories as admin_factories
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.tests import ModoAPITestCase
|
||||
from modoboa.postfix_autoreply import factories, models
|
||||
|
||||
|
||||
class ARMessageViewSetTestCase(ModoAPITestCase):
|
||||
"""API test case."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super(ARMessageViewSetTestCase, cls).setUpTestData()
|
||||
admin_factories.populate_database()
|
||||
cls.account = User.objects.get(username="user@test.com")
|
||||
cls.account2 = User.objects.get(username="user@test2.com")
|
||||
cls.arm = factories.ARmessageFactory(mbox=cls.account.mailbox)
|
||||
cls.arm2 = factories.ARmessageFactory(mbox=cls.account2.mailbox)
|
||||
|
||||
def test_retrieve_armessage(self):
|
||||
url = reverse("api:armessage-list")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
# Test filters
|
||||
response = self.client.get(
|
||||
url + "?mbox={}".format(self.account.mailbox.full_address)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
response = self.client.get(
|
||||
url + "?mbox={}".format(self.account.mailbox.address)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
response = self.client.get(url + "?mbox__user={}".format(self.account.pk))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
# Test retrieve
|
||||
url = reverse("api:armessage-detail", args=[response.data[0]["id"]])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_retrieve_armessage_domadmin(self):
|
||||
admin = User.objects.get(username="admin@test.com")
|
||||
self.client.logout()
|
||||
self.client.force_login(admin)
|
||||
url = reverse("api:armessage-list")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
url = reverse("api:armessage-detail", args=[self.arm2.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_retrieve_armessage_simpleuser(self):
|
||||
self.client.logout()
|
||||
self.client.force_login(self.account)
|
||||
url = reverse("api:armessage-list")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
url = reverse("api:armessage-detail", args=[self.arm2.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_create_armessage(self):
|
||||
url = reverse("api:armessage-list")
|
||||
account = User.objects.get(username="user@test2.com")
|
||||
data = {
|
||||
"mbox": account.mailbox.pk,
|
||||
"subject": "Je suis absent",
|
||||
"content": "Je reviens bientôt",
|
||||
"enabled": True,
|
||||
}
|
||||
response = self.client.post(url, data)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
def test_update_armessage(self):
|
||||
url = reverse("api:armessage-detail", args=[self.arm.pk])
|
||||
data = {"enabled": False}
|
||||
response = self.client.patch(url, data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.arm.refresh_from_db()
|
||||
self.assertFalse(self.arm.enabled)
|
||||
|
||||
data = {
|
||||
"mbox": self.account.mailbox.pk,
|
||||
"subject": "Je suis absent",
|
||||
"content": "Je reviens bientôt",
|
||||
"enabled": True,
|
||||
"untildate": (timezone.now() + relativedelta(days=1)).isoformat(),
|
||||
}
|
||||
response = self.client.patch(url, data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.arm.refresh_from_db()
|
||||
self.assertIsNot(self.arm.untildate, None)
|
||||
|
||||
def test_date_constraints(self):
|
||||
url = reverse("api:armessage-detail", args=[self.arm.pk])
|
||||
data = {
|
||||
"mbox": self.account.mailbox.pk,
|
||||
"subject": "Je suis absent",
|
||||
"content": "Je reviens bientôt",
|
||||
"enabled": True,
|
||||
"untildate": timezone.now().isoformat(),
|
||||
}
|
||||
response = self.client.patch(url, data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["untildate"][0], "This date is over")
|
||||
|
||||
data.update(
|
||||
{
|
||||
"fromdate": (timezone.now() + relativedelta(days=2)).isoformat(),
|
||||
"untildate": (timezone.now() + relativedelta(days=1)).isoformat(),
|
||||
}
|
||||
)
|
||||
response = self.client.patch(url, data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
response.data["untildate"][0], "Must be greater than start date"
|
||||
)
|
||||
|
||||
|
||||
class AccountARMessageViewSetTestCase(ModoAPITestCase):
|
||||
"""API test case."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
admin_factories.populate_database()
|
||||
cls.account = User.objects.get(username="user@test.com")
|
||||
|
||||
def test_get_armessage(self):
|
||||
url = reverse("api:account_armessage-armessage")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.client.logout()
|
||||
self.client.force_login(self.account)
|
||||
self.assertFalse(
|
||||
models.ARmessage.objects.filter(mbox=self.account.mailbox).exists()
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(
|
||||
models.ARmessage.objects.filter(mbox=self.account.mailbox).exists()
|
||||
)
|
||||
|
||||
def test_set_armessage(self):
|
||||
self.client.logout()
|
||||
self.client.force_login(self.account)
|
||||
url = reverse("api:account_armessage-armessage")
|
||||
data = {
|
||||
"enabled": True,
|
||||
"subject": "Je suis absent",
|
||||
"content": "Je reviendrai",
|
||||
"fromdate": "2024-03-16T10:00",
|
||||
"untildate": "2024-03-18T10:00",
|
||||
}
|
||||
response = self.client.put(url, data, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
armessage = models.ARmessage.objects.get(mbox=self.account.mailbox)
|
||||
self.assertTrue(armessage.enabled)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Autoreply API urls."""
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
from . import viewsets
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
router.register(r"armessages", viewsets.ARMessageViewSet, basename="armessage")
|
||||
router.register(
|
||||
r"account", viewsets.AccountARMessageViewSet, basename="account_armessage"
|
||||
)
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Autoreply viewsets."""
|
||||
|
||||
from django_filters import rest_framework as filters
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins, permissions, response, viewsets
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from modoboa.admin import models as admin_models
|
||||
from modoboa.lib.email_utils import split_mailbox
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from modoboa.postfix_autoreply import models
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ARMessageFilterSet(filters.FilterSet):
|
||||
"""Filter set for ARmessage."""
|
||||
|
||||
mbox = filters.CharFilter(method="filter_mbox")
|
||||
|
||||
class Meta:
|
||||
fields = ("mbox", "mbox__user")
|
||||
model = models.ARmessage
|
||||
|
||||
def filter_mbox(self, queryset, name, value):
|
||||
address, domain = split_mailbox(value)
|
||||
if address:
|
||||
queryset = queryset.filter(mbox__address__icontains=address)
|
||||
if domain:
|
||||
queryset = queryset.filter(mbox__domain__name__icontains=domain)
|
||||
return queryset
|
||||
|
||||
|
||||
class ARMessageViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""A viewset for ARmessage."""
|
||||
|
||||
filter_backends = (filters.DjangoFilterBackend,)
|
||||
filterset_class = ARMessageFilterSet
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
queryset = models.ARmessage.objects.select_related("mbox__domain", "mbox__user")
|
||||
serializer_class = serializers.ARMessageSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter queryset based on current user."""
|
||||
qset = super().get_queryset()
|
||||
role = self.request.user.role
|
||||
if role == "SimpleUsers":
|
||||
qset = qset.filter(mbox=self.request.user.mailbox)
|
||||
elif role in ["DomainAdmins", "Resellers"]:
|
||||
mailboxes = admin_models.Mailbox.objects.get_for_admin(self.request.user)
|
||||
qset = qset.filter(mbox__in=mailboxes)
|
||||
return qset
|
||||
|
||||
|
||||
class AccountARMessageViewSet(viewsets.ViewSet):
|
||||
"""A viewset dedicated to connected account."""
|
||||
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
serializer_class = serializers.AccountARMessageSerializer
|
||||
|
||||
@extend_schema(
|
||||
description="Get current auto-reply message",
|
||||
methods=["GET"],
|
||||
)
|
||||
@extend_schema(
|
||||
description="Set auto-reply message",
|
||||
methods=["PUT"],
|
||||
)
|
||||
@action(methods=["get", "put"], detail=False)
|
||||
def armessage(self, request):
|
||||
if not hasattr(request.user, "mailbox"):
|
||||
return response.Response(status=404)
|
||||
params = dict(param_tools.get_global_parameters("postfix_autoreply"))
|
||||
armessage, created = models.ARmessage.objects.get_or_create(
|
||||
mbox=self.request.user.mailbox,
|
||||
defaults={
|
||||
"subject": params["default_subject"],
|
||||
"content": params["default_content"],
|
||||
},
|
||||
)
|
||||
if request.method == "GET":
|
||||
serializer = serializers.AccountARMessageSerializer(armessage)
|
||||
return response.Response(serializer.data)
|
||||
serializer = serializers.AccountARMessageSerializer(
|
||||
armessage, data=request.data
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return response.Response(serializer.validated_data)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""AppConfig for modoboa_postfix_autoreply."""
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
|
||||
def load_settings():
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from modoboa.postfix_autoreply import forms
|
||||
|
||||
param_tools.registry.add(
|
||||
"global", forms.ParametersForm, gettext_lazy("Automatic replies")
|
||||
)
|
||||
|
||||
|
||||
class PostfixAutoreplyConfig(AppConfig):
|
||||
"""App configuration."""
|
||||
|
||||
name = "modoboa.postfix_autoreply"
|
||||
verbose_name = "Auto-reply functionality using Postfix"
|
||||
|
||||
def ready(self):
|
||||
load_settings()
|
||||
from . import handlers # NOQA:F401
|
||||
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Postfix auto reply factories."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import factory
|
||||
|
||||
from modoboa.admin import factories as admin_factories
|
||||
from . import models
|
||||
|
||||
|
||||
class ARmessageFactory(factory.django.DjangoModelFactory):
|
||||
"""Factory to create ARmessage."""
|
||||
|
||||
class Meta:
|
||||
model = models.ARmessage
|
||||
django_get_or_create = ("mbox",)
|
||||
|
||||
mbox = factory.SubFactory(admin_factories.MailboxFactory)
|
||||
subject = "I'm absent"
|
||||
content = "I'll write you back ASAP"
|
||||
enabled = True
|
||||
@@ -0,0 +1,163 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Custom forms."""
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
|
||||
from modoboa.lib import form_utils
|
||||
from modoboa.parameters import forms as param_forms, tools as param_tools
|
||||
from .models import ARmessage
|
||||
|
||||
|
||||
class ARmessageForm(forms.ModelForm):
|
||||
"""Form to define an auto-reply message."""
|
||||
|
||||
fromdate = forms.DateTimeField(
|
||||
label=gettext_lazy("From"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Activate your auto reply from this date. " "Format : YYYY-MM-DD HH:mm:ss"
|
||||
),
|
||||
widget=forms.TextInput(attrs={"class": "datefield form-control"}),
|
||||
)
|
||||
untildate = forms.DateTimeField(
|
||||
label=gettext_lazy("Until"),
|
||||
required=False,
|
||||
help_text=gettext_lazy(
|
||||
"Activate your auto reply until this date. " "Format : YYYY-MM-DD HH:mm:ss"
|
||||
),
|
||||
widget=forms.TextInput(attrs={"class": "datefield form-control"}),
|
||||
)
|
||||
subject = forms.CharField(widget=forms.TextInput(attrs={"class": "form-control"}))
|
||||
content = forms.CharField(
|
||||
widget=forms.Textarea(attrs={"class": "form-control"}),
|
||||
help_text=gettext_lazy(
|
||||
"The content of your answer. You can use the following variables, "
|
||||
"which will be automatically replaced by the appropriate value: "
|
||||
"%(name)s, %(fromdate)s, %(untildate)s"
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ARmessage
|
||||
fields = ("subject", "content", "enabled", "fromdate", "untildate")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.mailbox = args[0]
|
||||
super().__init__(*args[1:], **kwargs)
|
||||
self.fields = OrderedDict(
|
||||
(key, self.fields[key])
|
||||
for key in ["subject", "content", "fromdate", "untildate", "enabled"]
|
||||
)
|
||||
if not self.instance.pk:
|
||||
self.fields["subject"].initial = param_tools.get_global_parameter(
|
||||
"default_subject"
|
||||
)
|
||||
self.fields["content"].initial = param_tools.get_global_parameter(
|
||||
"default_content"
|
||||
) % {"name": self.mailbox.user.fullname}
|
||||
instance = kwargs.get("instance")
|
||||
if instance is not None:
|
||||
if instance.enabled:
|
||||
self.fields["fromdate"].initial = instance.fromdate.replace(
|
||||
second=0, microsecond=0
|
||||
)
|
||||
self.fields["untildate"].initial = kwargs["instance"].untildate
|
||||
else:
|
||||
self.fields["fromdate"].initial = None
|
||||
|
||||
def clean(self):
|
||||
"""Custom fields validaton.
|
||||
|
||||
We want to be sure that fromdate < untildate and that they are
|
||||
both in the future ONLY IF the autoreply is beeing activated.
|
||||
|
||||
"""
|
||||
cleaned_data = super().clean()
|
||||
if not cleaned_data.get("fromdate"):
|
||||
cleaned_data["fromdate"] = timezone.now()
|
||||
if not cleaned_data["enabled"]:
|
||||
return cleaned_data
|
||||
untildate = cleaned_data.get("untildate")
|
||||
if untildate is not None:
|
||||
if untildate < timezone.now():
|
||||
self.add_error("untildate", _("This date is over"))
|
||||
elif untildate < cleaned_data["fromdate"]:
|
||||
self.add_error("untildate", _("Must be greater than start date"))
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Custom save method."""
|
||||
instance = super().save(commit=False)
|
||||
instance.mbox = self.mailbox
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class ParametersForm(param_forms.AdminParametersForm):
|
||||
"""General parameters."""
|
||||
|
||||
app = "postfix_autoreply"
|
||||
|
||||
general_sep = form_utils.SeparatorField(label=gettext_lazy("General"))
|
||||
|
||||
autoreplies_timeout = forms.IntegerField(
|
||||
label=gettext_lazy("Automatic reply timeout"),
|
||||
initial=86400,
|
||||
help_text=gettext_lazy(
|
||||
"Timeout in seconds between two auto-replies to the same recipient"
|
||||
),
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
default_subject = forms.CharField(
|
||||
label=gettext_lazy("Default subject"),
|
||||
initial=gettext_lazy("I'm off"),
|
||||
help_text=gettext_lazy(
|
||||
"Default subject used when an auto-reply message is created "
|
||||
"automatically"
|
||||
),
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
default_content = forms.CharField(
|
||||
label=gettext_lazy("Default content"),
|
||||
initial=gettext_lazy(
|
||||
"""I'm currently off. I'll answer as soon as I come back.
|
||||
|
||||
Best regards,
|
||||
%(name)s
|
||||
"""
|
||||
),
|
||||
help_text=gettext_lazy(
|
||||
"Default content used when an auto-reply message is created "
|
||||
"automatically. The '%(name)s' macro will be replaced by the "
|
||||
"user's full name."
|
||||
),
|
||||
widget=forms.widgets.Textarea(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
def clean_default_content(self):
|
||||
"""Check if the provided value is valid.
|
||||
|
||||
Must be a valid format string which will be used with the %
|
||||
operator.
|
||||
"""
|
||||
tpl = self.cleaned_data["default_content"]
|
||||
try:
|
||||
tpl % {"name": "Antoine Nguyen"}
|
||||
except (KeyError, ValueError):
|
||||
raise forms.ValidationError(gettext_lazy("Invalid syntax"))
|
||||
return tpl
|
||||
|
||||
|
||||
def load_settings():
|
||||
"""Load app settings."""
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
param_tools.registry.add("global", ParametersForm, _("Automatic replies"))
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Django signal handlers for modoboa_postfix_autoreply."""
|
||||
|
||||
from django.urls import re_path
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from modoboa.admin import models as admin_models, signals as admin_signals
|
||||
from modoboa.core import signals as core_signals
|
||||
from modoboa.transport import models as tr_models
|
||||
|
||||
from . import forms, models
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=admin_models.Domain)
|
||||
def manage_transport_entry(sender, instance, **kwargs):
|
||||
"""Create or update a transport entry for this domain."""
|
||||
if kwargs.get("created"):
|
||||
tr_models.Transport.objects.get_or_create(
|
||||
pattern="autoreply.{}".format(instance), service="autoreply"
|
||||
)
|
||||
return
|
||||
oldname = getattr(instance, "oldname", "None")
|
||||
if oldname is None or oldname == instance.name:
|
||||
return
|
||||
tr_models.Transport.objects.filter(pattern="autoreply.{}".format(oldname)).update(
|
||||
pattern="autoreply.{}".format(instance.name)
|
||||
)
|
||||
qset = admin_models.AliasRecipient.objects.select_related(
|
||||
"alias", "r_mailbox"
|
||||
).filter(
|
||||
alias__domain=instance, alias__internal=True, address__contains="@autoreply"
|
||||
)
|
||||
for alr in qset:
|
||||
alr.address = alr.address.replace(oldname, instance.name)
|
||||
alr.save()
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=admin_models.Domain)
|
||||
def delete_transport_entry(sender, instance, **kwargs):
|
||||
"""Delete a transport entry."""
|
||||
tr_models.Transport.objects.filter(pattern="autoreply.{}".format(instance)).delete()
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=admin_models.Mailbox)
|
||||
def rename_autoreply_alias(sender, instance, **kwargs):
|
||||
"""Rename AR alias if needed."""
|
||||
old_address = getattr(instance, "old_full_address", None)
|
||||
if old_address is None or old_address == instance.full_address:
|
||||
return
|
||||
admin_models.AliasRecipient.objects.filter(
|
||||
address__contains="{}@autoreply".format(old_address)
|
||||
).update(address="{}@autoreply.{}".format(instance.full_address, instance.domain))
|
||||
|
||||
|
||||
@receiver(signals.post_delete, sender=admin_models.Mailbox)
|
||||
def delete_autoreply_alias(sender, instance, **kwargs):
|
||||
"""Delete alias."""
|
||||
admin_models.AliasRecipient.objects.filter(
|
||||
address="{}@autoreply.{}".format(instance.full_address, instance.domain)
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.ARmessage)
|
||||
def manage_autoreply_alias(sender, instance, **kwargs):
|
||||
"""Create or delete the alias."""
|
||||
ar_alias_address = "{}@autoreply.{}".format(
|
||||
instance.mbox.full_address, instance.mbox.domain
|
||||
)
|
||||
admin_models.Alias.objects.get(
|
||||
address=instance.mbox.full_address, domain=instance.mbox.domain, internal=True
|
||||
)
|
||||
alias, created = admin_models.Alias.objects.get_or_create(
|
||||
address=instance.mbox.full_address, domain=instance.mbox.domain, internal=True
|
||||
)
|
||||
if instance.enabled:
|
||||
admin_models.AliasRecipient.objects.get_or_create(
|
||||
alias=alias, address=ar_alias_address
|
||||
)
|
||||
else:
|
||||
admin_models.AliasRecipient.objects.filter(address=ar_alias_address).delete()
|
||||
|
||||
|
||||
@receiver(core_signals.extra_uprefs_routes)
|
||||
def extra_routes(sender, **kwargs):
|
||||
"""Add extra routes."""
|
||||
from . import views
|
||||
|
||||
return [re_path(r"^user/autoreply/$", views.autoreply, name="autoreply")]
|
||||
|
||||
|
||||
@receiver(core_signals.extra_static_content)
|
||||
def extra_js(sender, caller, st_type, user, **kwargs):
|
||||
"""Add static content."""
|
||||
if caller != "user_index" or st_type != "js":
|
||||
return ""
|
||||
return """function autoreply_cb() {
|
||||
$('.datefield').datetimepicker({
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
locale: '%(lang)s'
|
||||
});
|
||||
}
|
||||
""" % {
|
||||
"lang": user.language
|
||||
}
|
||||
|
||||
|
||||
@receiver(core_signals.extra_user_menu_entries)
|
||||
def menu(sender, location, user, **kwargs):
|
||||
"""Inject new menu entries."""
|
||||
if location != "uprefs_menu" or not hasattr(user, "mailbox"):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"name": "autoreply",
|
||||
"class": "ajaxnav",
|
||||
"url": "autoreply/",
|
||||
"label": _("Auto-reply message"),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@receiver(admin_signals.extra_account_forms)
|
||||
def extra_account_form(sender, user, account, **kwargs):
|
||||
"""Add autoreply form to the account edition form."""
|
||||
result = []
|
||||
if user.role in ("SuperAdmins", "DomainAdmins"):
|
||||
if hasattr(account, "mailbox"):
|
||||
extraform = {
|
||||
"id": "auto_reply_message",
|
||||
"title": _("Auto reply"),
|
||||
"cls": forms.ARmessageForm,
|
||||
"new_args": [account.mailbox],
|
||||
}
|
||||
result.append(extraform)
|
||||
return result
|
||||
|
||||
|
||||
@receiver(admin_signals.get_account_form_instances)
|
||||
def fill_account_tab(sender, user, account, **kwargs):
|
||||
"""Return form instance."""
|
||||
condition = user.role not in ("SuperAdmins", "DomainAdmins") or not hasattr(
|
||||
account, "mailbox"
|
||||
)
|
||||
if condition:
|
||||
return {}
|
||||
return {"auto_reply_message": account.mailbox.armessage_set.first()}
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Know problems and associated fixes."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from modoboa.admin import models as admin_models
|
||||
from modoboa.admin.management.commands.subcommands import _repair
|
||||
from . import models
|
||||
|
||||
|
||||
@_repair.known_problem
|
||||
def ensure_autoreplies_recipents_are_valids(**options):
|
||||
"""Sometime autoreply alias exists when ARmessage is not enabled."""
|
||||
deleted = 0
|
||||
qs = admin_models.AliasRecipient.objects.select_related("alias").filter(
|
||||
address__contains="@autoreply."
|
||||
)
|
||||
for alr in qs:
|
||||
address, domain = alr.alias.address.split("@")
|
||||
arqs = models.ARmessage.objects.filter(
|
||||
mbox__address=address, mbox__domain__name=domain
|
||||
)
|
||||
if not arqs.exists():
|
||||
_repair.log("Delete {0} (AR does not exist)".format(alr), **options)
|
||||
alr.delete()
|
||||
deleted += 1
|
||||
elif arqs.filter(enabled=False).exists():
|
||||
_repair.log("Delete {0} (AR is disabled)".format(alr), **options)
|
||||
alr.delete()
|
||||
deleted += 1
|
||||
if deleted:
|
||||
_repair.log("{0} alias recipient(s) deleted".format(deleted), **options)
|
||||
@@ -0,0 +1,206 @@
|
||||
import datetime
|
||||
import email
|
||||
import email.header
|
||||
import fileinput
|
||||
from io import StringIO
|
||||
import logging
|
||||
import smtplib
|
||||
import socket
|
||||
import sys
|
||||
from logging.handlers import SysLogHandler
|
||||
|
||||
from django.core.mail import EmailMessage
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.encoding import smart_str
|
||||
from django.utils.formats import localize
|
||||
|
||||
from modoboa.admin.models import Mailbox
|
||||
from modoboa.lib.email_utils import split_mailbox
|
||||
from modoboa.parameters import tools as param_tools
|
||||
from ...models import ARhistoric, ARmessage
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def safe_subject(msg):
|
||||
"""Clean message subject and return it."""
|
||||
decoded = email.header.decode_header(msg.get("Subject"))
|
||||
subject = ""
|
||||
for sub, charset in decoded:
|
||||
if isinstance(sub, str):
|
||||
subject += sub
|
||||
continue
|
||||
# charset can be None
|
||||
charset = charset if charset else "utf-8"
|
||||
try:
|
||||
subject += sub.decode(charset)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return " ".join(subject.split())
|
||||
|
||||
|
||||
def send_autoreply(sender, mailbox, armessage, original_msg):
|
||||
"""Send an autoreply message."""
|
||||
if armessage.fromdate > timezone.now():
|
||||
# Too soon, come back later
|
||||
return
|
||||
|
||||
condition = armessage.untildate is not None and armessage.untildate < timezone.now()
|
||||
if condition:
|
||||
# ARmessage has expired, disable it
|
||||
armessage.enabled = False
|
||||
armessage.save(update_fields=["enabled"])
|
||||
return
|
||||
|
||||
try:
|
||||
lastar = ARhistoric.objects.get(armessage=armessage.id, sender=sender)
|
||||
timeout = param_tools.get_global_parameter(
|
||||
"autoreplies_timeout", app="postfix_autoreply"
|
||||
)
|
||||
delta = datetime.timedelta(seconds=int(timeout))
|
||||
now = timezone.make_aware(
|
||||
datetime.datetime.now(), timezone.get_default_timezone()
|
||||
)
|
||||
if lastar.last_sent + delta > now:
|
||||
logger.debug(
|
||||
"no autoreply message sent because delta (%s) < timetout (%s)",
|
||||
delta,
|
||||
timeout,
|
||||
)
|
||||
return
|
||||
|
||||
except ARhistoric.DoesNotExist:
|
||||
lastar = ARhistoric()
|
||||
lastar.armessage = armessage
|
||||
lastar.sender = sender
|
||||
|
||||
headers = {"Auto-Submitted": "auto-replied", "Precedence": "bulk"}
|
||||
message_id = original_msg.get("Message-ID", "").strip("\n")
|
||||
if message_id:
|
||||
headers.update({"In-Reply-To": message_id, "References": message_id})
|
||||
|
||||
subject = safe_subject(original_msg)
|
||||
with translation.override(mailbox.user.language):
|
||||
context = {
|
||||
"name": mailbox.user.fullname,
|
||||
"fromdate": localize(armessage.fromdate),
|
||||
"untildate": "",
|
||||
}
|
||||
if armessage.untildate:
|
||||
context.update({"untildate": localize(armessage.untildate)})
|
||||
content = armessage.content % context
|
||||
msg = EmailMessage(
|
||||
"Auto: {} Re: {}".format(armessage.subject, subject),
|
||||
smart_str(content),
|
||||
mailbox.user.encoded_address,
|
||||
[sender],
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
msg.send()
|
||||
except smtplib.SMTPException as exp:
|
||||
logger.error("Failed to send autoreply message: %s", exp)
|
||||
sys.exit(1)
|
||||
|
||||
logger.debug("autoreply message sent to %s", mailbox.user.encoded_address)
|
||||
|
||||
lastar.last_sent = datetime.datetime.now()
|
||||
lastar.save()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Command definition."""
|
||||
|
||||
help = "Send autoreply emails" # NOQA:A003
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add extra arguments to command line."""
|
||||
parser.add_argument("--debug", action="store_true", dest="debug", default=False)
|
||||
parser.add_argument(
|
||||
"--syslog-socket-path", default="/dev/log", help="Path to syslog socket"
|
||||
)
|
||||
parser.add_argument("sender")
|
||||
parser.add_argument("recipient", nargs="+")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
handler = SysLogHandler(address=options["syslog_socket_path"])
|
||||
except socket.error as ex:
|
||||
if ex.errno == 2:
|
||||
# try the default, localhost:514
|
||||
handler = SysLogHandler()
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.ERROR)
|
||||
if options["debug"]:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
logger.debug(
|
||||
"autoreply sender=%s recipient=%s",
|
||||
options["sender"],
|
||||
",".join(options["recipient"]),
|
||||
)
|
||||
|
||||
sender = smart_str(options["sender"])
|
||||
|
||||
sender_localpart = split_mailbox(sender.lower())[0]
|
||||
if (
|
||||
(sender_localpart in ("mailer-daemon", "listserv", "majordomo"))
|
||||
or (sender_localpart.startswith("owner-"))
|
||||
or (sender_localpart.endswith("-request"))
|
||||
):
|
||||
logger.debug("Skip auto reply, this mail comes from a mailing list")
|
||||
return
|
||||
|
||||
content = StringIO()
|
||||
for line in fileinput.input([]):
|
||||
content.write(line)
|
||||
content.seek(0)
|
||||
|
||||
original_msg = email.message_from_file(content)
|
||||
|
||||
# Mailing list filter based on
|
||||
# https://tools.ietf.org/html/rfc5230#page-7
|
||||
ml_known_headers = [
|
||||
"List-Id",
|
||||
"List-Help",
|
||||
"List-Subscribe",
|
||||
"List-Unsubscribe",
|
||||
"List-Post",
|
||||
"List-Owner",
|
||||
"List-Archive",
|
||||
]
|
||||
from_ml = False
|
||||
for header in ml_known_headers:
|
||||
if header in original_msg:
|
||||
from_ml = True
|
||||
break
|
||||
condition = (
|
||||
original_msg.get("Precedence") == "bulk"
|
||||
or original_msg.get("X-Mailer") == "PHPMailer"
|
||||
or from_ml
|
||||
)
|
||||
if condition:
|
||||
logger.debug("Skip auto reply, this mail comes from a mailing list")
|
||||
return
|
||||
|
||||
recipients = [smart_str(rcpt) for rcpt in options["recipient"]]
|
||||
for fulladdress in recipients:
|
||||
address, domain = split_mailbox(fulladdress)
|
||||
try:
|
||||
mbox = Mailbox.objects.get(address=address, domain__name=domain)
|
||||
except Mailbox.DoesNotExist:
|
||||
msg = "Unknown recipient %s" % (fulladdress)
|
||||
logger.debug("autoreply %s", msg)
|
||||
continue
|
||||
try:
|
||||
armessage = ARmessage.objects.get(mbox=mbox.id, enabled=True)
|
||||
logger.debug("autoreply message found")
|
||||
except ARmessage.DoesNotExist:
|
||||
logger.debug("autoreply message not found")
|
||||
continue
|
||||
|
||||
send_autoreply(sender, mbox, armessage, original_msg)
|
||||
@@ -0,0 +1,134 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("admin", "__first__"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Alias",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
("full_address", models.CharField(max_length=255)),
|
||||
("autoreply_address", models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
"db_table": "postfix_autoreply_alias",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ARhistoric",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
("last_sent", models.DateTimeField(auto_now=True)),
|
||||
("sender", models.CharField(max_length=254)),
|
||||
],
|
||||
options={
|
||||
"db_table": "postfix_autoreply_arhistoric",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ARmessage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"subject",
|
||||
models.CharField(
|
||||
help_text="The subject that will appear in sent emails",
|
||||
max_length=255,
|
||||
verbose_name="subject",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content",
|
||||
models.TextField(
|
||||
help_text="The content that will appear in sent emails",
|
||||
verbose_name="content",
|
||||
),
|
||||
),
|
||||
(
|
||||
"enabled",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Activate/Deactivate your auto reply",
|
||||
verbose_name="enabled",
|
||||
),
|
||||
),
|
||||
("fromdate", models.DateTimeField(default=django.utils.timezone.now)),
|
||||
("untildate", models.DateTimeField(null=True, blank=True)),
|
||||
(
|
||||
"mbox",
|
||||
models.ForeignKey(to="admin.Mailbox", on_delete=models.CASCADE),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "postfix_autoreply_armessage",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Transport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
("domain", models.CharField(max_length=300)),
|
||||
("method", models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
"db_table": "postfix_autoreply_transport",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="arhistoric",
|
||||
name="armessage",
|
||||
field=models.ForeignKey(
|
||||
to="postfix_autoreply.ARmessage", on_delete=models.CASCADE
|
||||
),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="arhistoric",
|
||||
unique_together=set([("armessage", "sender")]),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("postfix_autoreply", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="transport",
|
||||
name="domain",
|
||||
field=models.CharField(max_length=300, db_index=True),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
def move_aliases(apps, schema_editor):
|
||||
"""Move exising aliases to the main table."""
|
||||
OldAlias = apps.get_model("postfix_autoreply", "Alias")
|
||||
Alias = apps.get_model("admin", "Alias")
|
||||
AliasRecipient = apps.get_model("admin", "AliasRecipient")
|
||||
try:
|
||||
ObjectDates = apps.get_model("admin", "ObjectDates")
|
||||
except LookupError:
|
||||
ObjectDates = None
|
||||
to_create = []
|
||||
for old_alias in OldAlias.objects.all():
|
||||
values = {"address": old_alias.full_address, "internal": True}
|
||||
try:
|
||||
alias = Alias.objects.get(**values)
|
||||
except Alias.DoesNotExist:
|
||||
if ObjectDates:
|
||||
values["dates"] = ObjectDates.objects.create()
|
||||
alias = Alias.objects.create(**values)
|
||||
to_create.append(
|
||||
AliasRecipient(address=old_alias.autoreply_address, alias=alias)
|
||||
)
|
||||
AliasRecipient.objects.bulk_create(to_create)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("postfix_autoreply", "0002_auto_20150728_1236"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(move_aliases)]
|
||||
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("postfix_autoreply", "0003_move_aliases"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="Alias",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def remove_useless_aliases(apps, schema_editor):
|
||||
"""Remove aliases linked to disabled messages."""
|
||||
ARmessage = apps.get_model("postfix_autoreply", "ARmessage")
|
||||
AliasRecipient = apps.get_model("admin", "AliasRecipient")
|
||||
qset = ARmessage.objects.select_related("mbox", "mbox__domain").filter(
|
||||
enabled=False
|
||||
)
|
||||
for armessage in qset:
|
||||
alr_address = "{0}@{1}@autoreply.{1}".format(
|
||||
armessage.mbox.address, armessage.mbox.domain
|
||||
)
|
||||
try:
|
||||
alr = AliasRecipient.objects.get(address=alr_address)
|
||||
except AliasRecipient.DoesNotExist:
|
||||
continue
|
||||
alias = alr.alias
|
||||
alr.delete()
|
||||
if not alias.recipients_count:
|
||||
alias.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("postfix_autoreply", "0004_delete_alias"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_useless_aliases),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("postfix_autoreply", "0005_auto_20151202_1623"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="transport",
|
||||
name="domain",
|
||||
field=models.CharField(max_length=253, db_index=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.15 on 2018-09-28 12:23
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def move_transport_entries(apps, schema_editor):
|
||||
"""Move old transport entries to new model."""
|
||||
pf_Transport = apps.get_model("postfix_autoreply", "Transport")
|
||||
Transport = apps.get_model("transport", "Transport")
|
||||
to_create = []
|
||||
for old_transport in pf_Transport.objects.all():
|
||||
to_create.append(Transport(pattern=old_transport.domain, service="autoreply"))
|
||||
Transport.objects.bulk_create(to_create)
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("transport", "0002_auto_20180928_1520"),
|
||||
("postfix_autoreply", "0006_auto_20160329_1501"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(move_transport_entries, backward),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.11 on 2018-09-28 12:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("postfix_autoreply", "0007_auto_20180928_1423"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="Transport",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.2.12 on 2022-09-26 07:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("postfix_autoreply", "0008_delete_transport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="arhistoric",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="armessage",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.2.17 on 2023-04-21 16:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("postfix_autoreply", "0009_auto_20220926_0913"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="arhistoric",
|
||||
name="id",
|
||||
field=models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="armessage",
|
||||
name="id",
|
||||
field=models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Postfix autoreply models."""
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from modoboa.admin.models import Mailbox
|
||||
|
||||
|
||||
class ARmessage(models.Model):
|
||||
"""Auto reply messages."""
|
||||
|
||||
mbox = models.ForeignKey(Mailbox, on_delete=models.CASCADE)
|
||||
subject = models.CharField(
|
||||
_("subject"),
|
||||
max_length=255,
|
||||
help_text=_("The subject that will appear in sent emails"),
|
||||
)
|
||||
content = models.TextField(
|
||||
_("content"), help_text=_("The content that will appear in sent emails")
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
_("enabled"), help_text=_("Activate/Deactivate your auto reply"), default=False
|
||||
)
|
||||
fromdate = models.DateTimeField(default=timezone.now)
|
||||
untildate = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "postfix_autoreply_armessage"
|
||||
|
||||
def __str__(self):
|
||||
return "AR<{}>: {}".format(self.mbox, self.enabled)
|
||||
|
||||
|
||||
class ARhistoric(models.Model):
|
||||
"""Auto reply historic."""
|
||||
|
||||
armessage = models.ForeignKey(ARmessage, on_delete=models.CASCADE)
|
||||
last_sent = models.DateTimeField(auto_now=True)
|
||||
sender = models.CharField(max_length=254)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("armessage", "sender")
|
||||
db_table = "postfix_autoreply_arhistoric"
|
||||
@@ -0,0 +1,12 @@
|
||||
{% load i18n form_tags %}
|
||||
|
||||
<h2>{% trans "Auto-reply message" %} <small>{% trans "Define a message to automatically send when you are off" %}</small></h2>
|
||||
<hr>
|
||||
<form class="form-horizontal" method="POST"
|
||||
action="{% url 'autoreply' %}"
|
||||
id="uprefs_form">{% csrf_token %}
|
||||
{% render_form form %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary col-lg-offset-8 col-md-offset-8 col-sm-offset-7 col-lg-1 col-md-1 col-sm-2" id="update" name="update">{% trans 'Update' %}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,453 @@
|
||||
"""modoboa-postfix-autoreply unit tests."""
|
||||
|
||||
import datetime
|
||||
from io import StringIO
|
||||
import sys
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from django.core import mail, management
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
|
||||
from modoboa.admin import factories as admin_factories, models as admin_models
|
||||
from modoboa.core.models import User
|
||||
from modoboa.lib.tests import ModoTestCase, ModoAPITestCase
|
||||
from modoboa.transport import models as tr_models
|
||||
|
||||
from . import factories, models
|
||||
|
||||
SIMPLE_EMAIL_CONTENT = """
|
||||
From: Homer Simpson <homer@simpson.test>
|
||||
Date: Wed, 15 Mar 2017 18:35:19 +0100
|
||||
Message-ID: <CAN0378wA1V0VJg5OxyavB2uJgAimMc2ttGSc-yvWsXTaKqnKuw@simpson.test>
|
||||
Subject: Test
|
||||
To: user@test.com
|
||||
Content-Type: multipart/alternative; boundary=001a114420be4c231a054ac85e75
|
||||
|
||||
--001a114420be4c231a054ac85e75
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
pouet
|
||||
|
||||
--001a114420be4c231a054ac85e75
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div dir="ltr">pouet<br></div>
|
||||
|
||||
--001a114420be4c231a054ac85e75--
|
||||
"""
|
||||
|
||||
SIMPLE_EMAIL_CONTENT_WITH_BAD_HEADER = """
|
||||
From: Homer Simpson <homer@simpson.test>
|
||||
Date: Wed, 15 Mar 2017 18:35:19 +0100
|
||||
Message-ID:
|
||||
<CAN0378wA1V0VJg5OxyavB2uJgAimMc2ttGSc-yvWsXTaKqnKuw@simpson.test>
|
||||
Subject: Test
|
||||
To: user@test.com
|
||||
Content-Type: multipart/alternative; boundary=001a114420be4c231a054ac85e75
|
||||
|
||||
--001a114420be4c231a054ac85e75
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
pouet
|
||||
|
||||
--001a114420be4c231a054ac85e75
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div dir="ltr">pouet<br></div>
|
||||
|
||||
--001a114420be4c231a054ac85e75--
|
||||
"""
|
||||
|
||||
ENCODED_EMAIL_SUBJECT = r"""
|
||||
From: Homer Simpson <homer@simpson.test>
|
||||
Date: Wed, 15 Mar 2017 18:35:19 +0100
|
||||
Message-ID: <CAN0378wA1V0VJg5OxyavB2uJgAimMc2ttGSc-yvWsXTaKqnKuw@simpson.test>
|
||||
Subject: =?utf-8?q?A_m=C3=A9_non!!?=\n =?utf-8?q?_=C3=A7a_va_pas!!?=
|
||||
To: user@test.com
|
||||
Content-Type: multipart/alternative; boundary=001a114420be4c231a054ac85e75
|
||||
|
||||
--001a114420be4c231a054ac85e75
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
pouet
|
||||
|
||||
--001a114420be4c231a054ac85e75
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div dir="ltr">pouet<br></div>
|
||||
|
||||
--001a114420be4c231a054ac85e75--
|
||||
"""
|
||||
|
||||
EMAIL_FROM_ML_CONTENT = """
|
||||
From: Homer Simpson <homer@simpson.test>
|
||||
Date: Wed, 15 Mar 2017 18:35:19 +0100
|
||||
Message-ID: <CAN0378wA1V0VJg5OxyavB2uJgAimMc2ttGSc-yvWsXTaKqnKuw@simpson.test>
|
||||
List-Id: "Test list" <list@test.org>
|
||||
List-Archive: <http://lists.test.org/pipermail/test-list/>
|
||||
Subject: Test
|
||||
To: user@test.com
|
||||
Content-Type: multipart/alternative; boundary=001a114420be4c231a054ac85e75
|
||||
|
||||
--001a114420be4c231a054ac85e75
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
pouet
|
||||
|
||||
--001a114420be4c231a054ac85e75
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div dir="ltr">pouet<br></div>
|
||||
|
||||
--001a114420be4c231a054ac85e75--
|
||||
"""
|
||||
|
||||
|
||||
class EventsTestCase(ModoTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
"""Create test data."""
|
||||
super(EventsTestCase, cls).setUpTestData()
|
||||
admin_factories.populate_database()
|
||||
|
||||
def test_domain_created_event(self):
|
||||
values = {
|
||||
"name": "domain.tld",
|
||||
"quota": 100,
|
||||
"default_mailbox_quota": 1,
|
||||
"create_dom_admin": False,
|
||||
"stepid": "step3",
|
||||
"type": "domain",
|
||||
}
|
||||
self.ajax_post(reverse("admin:domain_add"), values)
|
||||
self.assertTrue(
|
||||
tr_models.Transport.objects.filter(pattern="autoreply.domain.tld").exists()
|
||||
)
|
||||
|
||||
def test_domain_deleted_event(self):
|
||||
dom = admin_models.Domain.objects.get(name="test.com")
|
||||
self.ajax_post(reverse("admin:domain_delete", args=[dom.id]), {})
|
||||
with self.assertRaises(tr_models.Transport.DoesNotExist):
|
||||
tr_models.Transport.objects.get(pattern="autoreply.test.com")
|
||||
|
||||
def test_domain_modified_event(self):
|
||||
values = {
|
||||
"name": "test.fr",
|
||||
"quota": 100,
|
||||
"default_mailbox_quota": 1,
|
||||
"enabled": True,
|
||||
"type": "domain",
|
||||
}
|
||||
dom = admin_models.Domain.objects.get(name="test.com")
|
||||
self.ajax_post(reverse("admin:domain_change", args=[dom.id]), values)
|
||||
self.assertTrue(
|
||||
tr_models.Transport.objects.filter(pattern="autoreply.test.fr").exists()
|
||||
)
|
||||
self.assertEqual(
|
||||
admin_models.Alias.objects.filter(domain=dom, internal=True).count(), 2
|
||||
)
|
||||
for alr in admin_models.AliasRecipient.objects.filter(
|
||||
address__contains="@test.fr"
|
||||
):
|
||||
self.assertIn("autoreply.test.fr", alr.address)
|
||||
|
||||
def test_armessage_postsave_event(self):
|
||||
values = {
|
||||
"username": "leon@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "leon@test.com",
|
||||
"subject": "test",
|
||||
"content": "test",
|
||||
"enabled": True,
|
||||
"language": "en",
|
||||
}
|
||||
account = User.objects.get(username="user@test.com")
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.id]), values)
|
||||
self.assertTrue(
|
||||
admin_models.AliasRecipient.objects.filter(
|
||||
alias__address="leon@test.com",
|
||||
alias__internal=True,
|
||||
address="leon@test.com@autoreply.test.com",
|
||||
).exists()
|
||||
)
|
||||
values["enabled"] = False
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.id]), values)
|
||||
self.assertFalse(
|
||||
admin_models.AliasRecipient.objects.filter(
|
||||
alias__address="leon@test.com",
|
||||
alias__internal=True,
|
||||
address="leon@test.com@autoreply.test.com",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_mailbox_deleted_event(self):
|
||||
account = User.objects.get(username="user@test.com")
|
||||
self.ajax_post(reverse("admin:account_delete", args=[account.id]), {})
|
||||
self.assertFalse(
|
||||
admin_models.Alias.objects.filter(
|
||||
address="user@test.com", internal=True
|
||||
).exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
models.ARmessage.objects.filter(
|
||||
mbox__address="user", mbox__domain__name="test.com"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_modify_mailbox_event(self):
|
||||
"""Rename mailbox."""
|
||||
account = User.objects.get(username="user@test.com")
|
||||
factories.ARmessageFactory(mbox=account.mailbox)
|
||||
values = {
|
||||
"username": "leon@test.com",
|
||||
"first_name": "Tester",
|
||||
"last_name": "Toto",
|
||||
"role": "SimpleUsers",
|
||||
"quota_act": True,
|
||||
"is_active": True,
|
||||
"email": "leon@test.com",
|
||||
"subject": "test",
|
||||
"content": "test",
|
||||
"enabled": True,
|
||||
"language": "en",
|
||||
}
|
||||
self.ajax_post(reverse("admin:account_change", args=[account.id]), values)
|
||||
self.assertFalse(
|
||||
admin_models.AliasRecipient.objects.filter(
|
||||
alias__address="user@test.com",
|
||||
alias__internal=True,
|
||||
address="user@test.com@autoreply.test.com",
|
||||
).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
admin_models.AliasRecipient.objects.filter(
|
||||
alias__address="leon@test.com",
|
||||
alias__internal=True,
|
||||
address="leon@test.com@autoreply.test.com",
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class FormTestCase(ModoTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
"""Create test data."""
|
||||
super(FormTestCase, cls).setUpTestData()
|
||||
admin_factories.populate_database()
|
||||
|
||||
def setUp(self):
|
||||
"""Initialize tests."""
|
||||
super(FormTestCase, self).setUp()
|
||||
self.client.logout()
|
||||
self.client.login(username="user@test.com", password="toto")
|
||||
|
||||
def test_form_display(self):
|
||||
"""Check if form is displayed."""
|
||||
url = reverse("core:user_index")
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "function autoreply_cb()")
|
||||
self.assertContains(response, 'name="autoreply"')
|
||||
|
||||
response = self.ajax_get(reverse("autoreply"))
|
||||
self.assertIn("%(name)s", response["content"])
|
||||
|
||||
def test_set_autoreply(self):
|
||||
values = {"subject": "test", "content": "I'm off", "enabled": True}
|
||||
self.ajax_post(reverse("autoreply"), values)
|
||||
account = User.objects.get(username="user@test.com")
|
||||
arm = models.ARmessage.objects.get(mbox=account.mailbox)
|
||||
self.assertEqual(arm.subject, "test")
|
||||
self.assertTrue(arm.enabled)
|
||||
self.assertFalse(arm.untildate)
|
||||
self.assertTrue(arm.fromdate)
|
||||
|
||||
def test_set_autoreply_in_past(self):
|
||||
"""Create an autoreply with from date expired."""
|
||||
fromdate = timezone.localtime(
|
||||
timezone.now().replace(year=2014, month=1, day=1, hour=12, microsecond=0)
|
||||
)
|
||||
values = {
|
||||
"subject": "test",
|
||||
"content": "I'm off",
|
||||
"enabled": True,
|
||||
"fromdate": fromdate.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
self.ajax_post(reverse("autoreply"), values)
|
||||
account = User.objects.get(username="user@test.com")
|
||||
arm = models.ARmessage.objects.get(mbox=account.mailbox)
|
||||
self.assertEqual(timezone.localtime(arm.fromdate), fromdate)
|
||||
|
||||
def test_set_autoreply_dates(self):
|
||||
"""Create an autoreply with from and to dates."""
|
||||
fromdate = timezone.localtime(timezone.now())
|
||||
untildate = fromdate + datetime.timedelta(days=1)
|
||||
values = {
|
||||
"subject": "test",
|
||||
"content": "I'm off",
|
||||
"enabled": True,
|
||||
"fromdate": fromdate.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"untildate": untildate.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
self.ajax_post(reverse("autoreply"), values)
|
||||
account = User.objects.get(username="user@test.com")
|
||||
arm = models.ARmessage.objects.get(mbox=account.mailbox)
|
||||
self.assertEqual(
|
||||
timezone.localtime(arm.fromdate), fromdate.replace(microsecond=0)
|
||||
)
|
||||
self.assertEqual(
|
||||
timezone.localtime(arm.untildate), untildate.replace(microsecond=0)
|
||||
)
|
||||
|
||||
|
||||
class RepairTestCase(ModoTestCase):
|
||||
"""Check repair command."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
"""Create some data."""
|
||||
super(RepairTestCase, cls).setUpTestData()
|
||||
admin_factories.populate_database()
|
||||
|
||||
def test_management_command(self):
|
||||
"""Check if problems are fixed."""
|
||||
mbox = admin_models.Mailbox.objects.get(user__username="user@test.com")
|
||||
alias = admin_models.Alias.objects.get(internal=True, address=mbox.full_address)
|
||||
arm = factories.ARmessageFactory(mbox=mbox, enabled=False)
|
||||
ar_address = "{}@autoreply.{}".format(mbox.full_address, mbox.domain.name)
|
||||
admin_factories.AliasRecipientFactory(alias=alias, address=ar_address)
|
||||
management.call_command("modo", "repair", "--quiet")
|
||||
self.assertFalse(
|
||||
admin_models.AliasRecipient.objects.filter(address=ar_address).exists()
|
||||
)
|
||||
admin_factories.AliasRecipientFactory(alias=alias, address=ar_address)
|
||||
arm.delete()
|
||||
management.call_command("modo", "repair", "--quiet")
|
||||
self.assertFalse(
|
||||
admin_models.AliasRecipient.objects.filter(address=ar_address).exists()
|
||||
)
|
||||
|
||||
|
||||
class ManagementCommandTestCase(ModoTestCase):
|
||||
"""Management command related tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls): # NOQA:N802
|
||||
"""Create some data."""
|
||||
super(ManagementCommandTestCase, cls).setUpTestData()
|
||||
admin_factories.populate_database()
|
||||
cls.account = User.objects.get(username="user@test.com")
|
||||
|
||||
def setUp(self):
|
||||
"""Replace stdin."""
|
||||
super(ManagementCommandTestCase, self).setUp()
|
||||
self.stdin = sys.stdin
|
||||
sys.stdin = StringIO(SIMPLE_EMAIL_CONTENT.strip())
|
||||
self.arm = factories.ARmessageFactory(mbox=self.account.mailbox)
|
||||
|
||||
def tearDown(self):
|
||||
"""Restore stdin."""
|
||||
sys.stdin = self.stdin
|
||||
|
||||
def test_simple_case(self):
|
||||
"""Check basic autoreply."""
|
||||
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertTrue(models.ARhistoric.objects.filter(armessage=self.arm).exists())
|
||||
|
||||
# Try to send a new one
|
||||
sys.stdin.seek(0)
|
||||
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
def test_armessage_date_constraints(self):
|
||||
"""Check date constraints."""
|
||||
account = User.objects.get(username="admin@test.com")
|
||||
arm = factories.ARmessageFactory(
|
||||
mbox=account.mailbox, fromdate=timezone.now() + relativedelta(days=1)
|
||||
)
|
||||
management.call_command("autoreply", "homer@simpson.com", "admin@test.com")
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
arm.fromdate = timezone.now() - relativedelta(days=2)
|
||||
arm.untildate = timezone.now() - relativedelta(days=1)
|
||||
arm.save()
|
||||
sys.stdin.seek(0)
|
||||
management.call_command("autoreply", "homer@simpson.com", "admin@test.com")
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
arm.refresh_from_db()
|
||||
self.assertFalse(arm.enabled)
|
||||
|
||||
def test_recipient_does_not_exist(self):
|
||||
"""Message received for non local recipient."""
|
||||
management.call_command("autoreply", "homer@simpson.com", "pouet@test.fr")
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_no_ar_message_defined(self):
|
||||
"""No AR defined for local recipient."""
|
||||
management.call_command("autoreply", "homer@simpson.com", "admin@test.com")
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_encoded_message(self):
|
||||
"""Message received with encoded header"""
|
||||
sys.stdin = StringIO(ENCODED_EMAIL_SUBJECT.strip())
|
||||
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
def test_message_from_ml(self):
|
||||
"""Message received from a mailing list."""
|
||||
sys.stdin = StringIO(EMAIL_FROM_ML_CONTENT.strip())
|
||||
management.call_command("autoreply", "mailer-daemon@list.test", "user@test.com")
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
sys.stdin.seek(0)
|
||||
management.call_command("autoreply", "sender@list.test", "user@test.com")
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_variable_substitution(self):
|
||||
"""Check when message contains variables."""
|
||||
tz = timezone.get_current_timezone()
|
||||
self.arm.fromdate = datetime.datetime(2017, 12, 8, 14, 0, 0, 0).replace(
|
||||
tzinfo=tz
|
||||
)
|
||||
self.arm.content = "%(name)s %(fromdate)s %(untildate)s"
|
||||
self.arm.save()
|
||||
self.account.language = "fr"
|
||||
self.account.save(update_fields=["language"])
|
||||
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(
|
||||
"user@test.com 8 décembre 2017 14:00", mail.outbox[0].body.strip()
|
||||
)
|
||||
|
||||
def test_untildate_substitution(self):
|
||||
"""Check substitution of until date."""
|
||||
tz = timezone.get_current_timezone()
|
||||
self.arm.fromdate = datetime.datetime(2017, 12, 8, 14, 0, 0, 0).replace(
|
||||
tzinfo=tz
|
||||
)
|
||||
self.arm.untildate = timezone.now() + relativedelta(hours=1)
|
||||
self.arm.content = "%(name)s %(fromdate)s %(untildate)s"
|
||||
self.arm.save()
|
||||
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(
|
||||
"user@test.com Dec. 8, 2017, 2 p.m. {}".format(
|
||||
date_format(self.arm.untildate, "DATETIME_FORMAT")
|
||||
),
|
||||
mail.outbox[0].body.strip(),
|
||||
)
|
||||
|
||||
def test_message_with_bad_headers(self):
|
||||
"""Message received with bad header (including newline)"""
|
||||
sys.stdin = StringIO(SIMPLE_EMAIL_CONTENT_WITH_BAD_HEADER.strip())
|
||||
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from modoboa.admin.lib import needs_mailbox
|
||||
from modoboa.admin.models import Mailbox
|
||||
from modoboa.lib.web_utils import render_to_json_response
|
||||
from .forms import ARmessageForm
|
||||
from .models import ARmessage
|
||||
|
||||
|
||||
@login_required
|
||||
@needs_mailbox()
|
||||
def autoreply(request, tplname="modoboa_postfix_autoreply/autoreply.html"):
|
||||
mb = Mailbox.objects.get(user=request.user.id)
|
||||
try:
|
||||
arm = ARmessage.objects.get(mbox=mb.id)
|
||||
except ARmessage.DoesNotExist:
|
||||
arm = None
|
||||
if request.method == "POST":
|
||||
if arm:
|
||||
form = ARmessageForm(mb, request.POST, instance=arm)
|
||||
else:
|
||||
form = ARmessageForm(mb, request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return render_to_json_response(
|
||||
_("Auto reply message updated successfully.")
|
||||
)
|
||||
|
||||
return render_to_json_response({"form_errors": form.errors}, status=400)
|
||||
|
||||
form = ARmessageForm(mb, instance=arm)
|
||||
return render_to_json_response(
|
||||
{
|
||||
"content": render_to_string(tplname, {"form": form}, request),
|
||||
"onload_cb": "autoreply_cb",
|
||||
}
|
||||
)
|
||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
||||
path("", include("modoboa.maillog.api.v2.urls")),
|
||||
path("", include("modoboa.transport.api.v2.urls")),
|
||||
path("", include("modoboa.pdfcredentials.api.v2.urls")),
|
||||
path("", include("modoboa.postfix_autoreply.api.v2.urls")),
|
||||
]
|
||||
|
||||
urlpatterns += exts_pool.get_urls(category="api")
|
||||
|
||||
@@ -94,6 +94,7 @@ MODOBOA_APPS = (
|
||||
"modoboa.pdfcredentials",
|
||||
"modoboa.dmarc",
|
||||
"modoboa.imap_migration",
|
||||
"modoboa.postfix_autoreply",
|
||||
# Modoboa extensions here.
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user