Merge pull request #3205 from modoboa/feature/postfix_autoreply

Postfix autoreply merged back to core repo
This commit is contained in:
Antoine Nguyen
2024-03-20 10:41:00 +01:00
committed by GitHub
98 changed files with 40788 additions and 26070 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

+28
View File
@@ -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.
-3
View File
@@ -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>`_ | |
+---------------------------------------------------------+-------------------------+------------------------------------------------------+
+41
View File
@@ -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
=====
+6
View File
@@ -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') })
})
}
+5 -1
View File
@@ -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
+16 -34
View File
@@ -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 =
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
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
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
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
+8 -29
View File
@@ -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,
},
},
+7 -2
View File
@@ -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>
+22
View File
@@ -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")
+10
View File
@@ -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)
+3
View File
@@ -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())
+8
View File
@@ -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)
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
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
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
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
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
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
+1 -1
View File
@@ -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))
+15
View File
@@ -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")
+169
View File
@@ -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)
+12
View File
@@ -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)
+24
View File
@@ -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
+23
View File
@@ -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
+163
View File
@@ -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"))
+147
View File
@@ -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"
),
),
]
+44
View File
@@ -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>
+453
View File
@@ -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)
+41
View File
@@ -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",
}
)
+1
View File
@@ -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")
+1
View File
@@ -94,6 +94,7 @@ MODOBOA_APPS = (
"modoboa.pdfcredentials",
"modoboa.dmarc",
"modoboa.imap_migration",
"modoboa.postfix_autoreply",
# Modoboa extensions here.
)